diff --git a/README.md b/README.md index 6c79b4f91..6cf237430 100755 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

NEBULA: A Platform for Decentralized Federated Learning

- nebula-dfl.com | + nebula-dfl.com | nebula-dfl.eu | federatedlearning.inf.um.es

diff --git a/analysis/README.md b/analysis/README.md index 5118b091c..3f651d934 100755 --- a/analysis/README.md +++ b/analysis/README.md @@ -7,7 +7,7 @@

NEBULA: A Platform for Decentralized Federated Learning

- nebula-dfl.com | + nebula-dfl.com | nebula-dfl.eu | federatedlearning.inf.um.es

diff --git a/app/main.py b/app/main.py index a9a4733f0..3d6c300ad 100755 --- a/app/main.py +++ b/app/main.py @@ -4,8 +4,8 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "..")) # Parent directory where is the NEBULA module import nebula -from nebula.controller import Controller -from nebula.scenarios import ScenarioManagement +from nebula.controller.controller import Controller +from nebula.controller.scenarios import ScenarioManagement argparser = argparse.ArgumentParser(description="Controller of NEBULA platform", add_help=False) @@ -54,8 +54,6 @@ help="Statistics port (default: 8080)", ) -argparser.add_argument("-t", "--test", dest="test", action="store_true", default=False, help="Run tests") - argparser.add_argument( "-st", "--stop", diff --git a/docs/_prebuilt/developerguide.md b/docs/_prebuilt/developerguide.md index 43eb0c182..b54565017 100644 --- a/docs/_prebuilt/developerguide.md +++ b/docs/_prebuilt/developerguide.md @@ -829,8 +829,8 @@ To add a new message to the application, follow these steps: FEDERATION_READY = 3; } Action action = 1; - repeated string arguments = 2; - int32 round = 3; + repeated string arguments = 2; + int32 round = 3; } ``` @@ -905,4 +905,4 @@ Note that **EventType** is the class that represents the event (not a specific i When the event is published, all subscribed listeners for that event type will be triggered. As mentioned, there are three different **publish** functions, each tied to a specific type of event. -Finally, to **create a new event**, go to the file **/core/nebulaevents.py**. Depending on the type of event you wish to implement, create a class that extends one of the three native event types. After doing this, the usage of your new event is transparent to the rest of the system, and you can use the functions described above without any issues. \ No newline at end of file +Finally, to **create a new event**, go to the file **/core/nebulaevents.py**. Depending on the type of event you wish to implement, create a class that extends one of the three native event types. After doing this, the usage of your new event is transparent to the rest of the system, and you can use the functions described above without any issues. diff --git a/nebula/addons/attacks/communications/communicationattack.py b/nebula/addons/attacks/communications/communicationattack.py index 6552e9e52..bacae998c 100644 --- a/nebula/addons/attacks/communications/communicationattack.py +++ b/nebula/addons/attacks/communications/communicationattack.py @@ -1,9 +1,11 @@ import logging import random +import random import types from abc import abstractmethod from nebula.addons.attacks.attacks import Attack +from nebula.core.network.communications import CommunicationsManager class CommunicationAttack(Attack): @@ -46,21 +48,23 @@ async def select_targets(self): if self.selection_interval: if self.last_selection_round % self.selection_interval == 0: logging.info("Recalculating targets...") - all_nodes = await self.engine.cm.get_addrs_current_connections(only_direct=True) + all_nodes = await CommunicationsManager.get_instance().get_addrs_current_connections(only_direct=True) num_targets = max(1, int(len(all_nodes) * (self.selectivity_percentage / 100))) self.targets = set(random.sample(list(all_nodes), num_targets)) elif not self.targets: logging.info("Calculating targets...") - all_nodes = await self.engine.cm.get_addrs_current_connections(only_direct=True) + all_nodes = await CommunicationsManager.get_instance().get_addrs_current_connections(only_direct=True) num_targets = max(1, int(len(all_nodes) * (self.selectivity_percentage / 100))) self.targets = set(random.sample(list(all_nodes), num_targets)) else: logging.info("All neighbors selected as targets") - self.targets = await self.engine.cm.get_addrs_current_connections(only_direct=True) + self.targets = await CommunicationsManager.get_instance().get_addrs_current_connections(only_direct=True) logging.info(f"Selected {self.selectivity_percentage}% targets from neighbors: {self.targets}") self.last_selection_round += 1 + self.last_selection_round += 1 + async def _inject_malicious_behaviour(self): """Inject malicious behavior into the target method.""" decorated_method = self.decorator(self.decorator_args)(self.original_method) diff --git a/nebula/addons/attacks/communications/delayerattack.py b/nebula/addons/attacks/communications/delayerattack.py index afe611fde..05daae735 100644 --- a/nebula/addons/attacks/communications/delayerattack.py +++ b/nebula/addons/attacks/communications/delayerattack.py @@ -3,6 +3,7 @@ from functools import wraps from nebula.addons.attacks.communications.communicationattack import CommunicationAttack +from nebula.core.network.communications import CommunicationsManager class DelayerAttack(CommunicationAttack): @@ -32,8 +33,8 @@ def __init__(self, engine, attack_params: dict): super().__init__( engine, - engine._cm, - "send_model", + CommunicationsManager.get_instance(), + "send_message", round_start, round_stop, attack_interval, @@ -43,27 +44,27 @@ def __init__(self, engine, attack_params: dict): ) def decorator(self, delay: int): - """ - Decorator that adds a delay to the execution of the original method. + """ + Decorator that adds a delay to the execution of the original method. - Args: - delay (int): The time in seconds to delay the method execution. + Args: + delay (int): The time in seconds to delay the method execution. - Returns: - function: A decorator function that wraps the target method with the delay logic. - """ + Returns: + function: A decorator function that wraps the target method with the delay logic. + """ - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - if len(args) > 1: - dest_addr = args[1] - if dest_addr in self.targets: - logging.info(f"[DelayerAttack] Delaying model propagation to {dest_addr} by {delay} seconds") - await asyncio.sleep(delay) - _, *new_args = args # Exclude self argument - return await func(*new_args) + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + if len(args) == 4 and args[3] == "model": + dest_addr = args[1] + if dest_addr in self.targets: + logging.info(f"[DelayerAttack] Delaying model propagation to {dest_addr} by {delay} seconds") + await asyncio.sleep(delay) + _, *new_args = args # Exclude self argument + return await func(*new_args) - return wrapper + return wrapper - return decorator + return decorator diff --git a/nebula/addons/attacks/communications/floodingattack.py b/nebula/addons/attacks/communications/floodingattack.py index 643969739..146854fa3 100644 --- a/nebula/addons/attacks/communications/floodingattack.py +++ b/nebula/addons/attacks/communications/floodingattack.py @@ -1,9 +1,8 @@ -import asyncio import logging from functools import wraps -import time from nebula.addons.attacks.communications.communicationattack import CommunicationAttack +from nebula.core.network.communications import CommunicationsManager class FloodingAttack(CommunicationAttack): @@ -35,8 +34,8 @@ def __init__(self, engine, attack_params: dict): super().__init__( engine, - engine._cm, - "send_model", + CommunicationsManager.get_instance(), + "send_message", round_start, round_stop, attack_interval, @@ -59,7 +58,7 @@ def decorator(self, flooding_factor: int): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - if len(args) > 1: + if len(args) == 4 and args[3] == "model": dest_addr = args[1] if dest_addr in self.targets: logging.info(f"[FloodingAttack] Flooding message to {dest_addr} by {flooding_factor} times") @@ -68,13 +67,11 @@ async def wrapper(*args, **kwargs): logging.info( f"[FloodingAttack] Sending duplicate {i + 1}/{flooding_factor} to {dest_addr}" ) - _, dest_addr, _, serialized_model, weight = args # Exclude self argument - new_args = [dest_addr, i, serialized_model, weight] + _, *new_args = args # Exclude self argument await func(*new_args, **kwargs) - _, dest_addr, _, serialized_model, weight = args # Exclude self argument - new_args = [dest_addr, i, serialized_model, weight] + _, *new_args = args return await func(*new_args) - + return wrapper return decorator diff --git a/nebula/addons/attacks/dataset/datasetattack.py b/nebula/addons/attacks/dataset/datasetattack.py index c740b8b91..812e09bb3 100644 --- a/nebula/addons/attacks/dataset/datasetattack.py +++ b/nebula/addons/attacks/dataset/datasetattack.py @@ -35,9 +35,11 @@ async def attack(self): """ if self.engine.round not in range(self.round_start_attack, self.round_stop_attack + 1): pass - elif self.engine.round == self.round_stop_attack: + elif self.engine.round == self.round_stop_attack: logging.info(f"[{self.__class__.__name__}] Stopping attack") - elif self.engine.round >= self.round_start_attack and ((self.engine.round - self.round_start_attack) % self.attack_interval == 0): + elif self.engine.round >= self.round_start_attack and ( + (self.engine.round - self.round_start_attack) % self.attack_interval == 0 + ): logging.info(f"[{self.__class__.__name__}] Performing attack") self.engine.trainer.datamodule.train_set = self.get_malicious_dataset() diff --git a/nebula/addons/attacks/dataset/labelflipping.py b/nebula/addons/attacks/dataset/labelflipping.py index 1116a9019..41d1e106e 100755 --- a/nebula/addons/attacks/dataset/labelflipping.py +++ b/nebula/addons/attacks/dataset/labelflipping.py @@ -10,6 +10,7 @@ import copy import logging import random + import numpy as np from nebula.addons.attacks.dataset.datasetattack import DatasetAttack diff --git a/nebula/addons/attacks/model/gllneuroninversion.py b/nebula/addons/attacks/model/gllneuroninversion.py index 45686ad86..18cce6ddf 100644 --- a/nebula/addons/attacks/model/gllneuroninversion.py +++ b/nebula/addons/attacks/model/gllneuroninversion.py @@ -34,7 +34,7 @@ def __init__(self, engine, attack_params): raise ValueError(f"Missing required attack parameter: {e}") except ValueError: raise ValueError("Invalid value in attack_params. Ensure all values are integers.") - + super().__init__(engine, round_start, round_stop, attack_interval) def model_attack(self, received_weights): diff --git a/nebula/addons/attacks/model/modelattack.py b/nebula/addons/attacks/model/modelattack.py index 643f3d728..7f1c719bb 100644 --- a/nebula/addons/attacks/model/modelattack.py +++ b/nebula/addons/attacks/model/modelattack.py @@ -110,7 +110,9 @@ async def attack(self): elif self.engine.round == self.round_stop_attack: logging.info(f"[{self.__class__.__name__}] Stopping attack") await self._restore_original_behaviour() - elif (self.engine.round == self.round_start_attack) or ((self.engine.round - self.round_start_attack) % self.attack_interval == 0): + elif (self.engine.round == self.round_start_attack) or ( + (self.engine.round - self.round_start_attack) % self.attack_interval == 0 + ): logging.info(f"[{self.__class__.__name__}] Performing attack") await self._inject_malicious_behaviour() else: diff --git a/nebula/addons/attacks/model/swappingweights.py b/nebula/addons/attacks/model/swappingweights.py index a194ba8ae..70ac21834 100644 --- a/nebula/addons/attacks/model/swappingweights.py +++ b/nebula/addons/attacks/model/swappingweights.py @@ -40,7 +40,7 @@ def __init__(self, engine, attack_params): raise ValueError(f"Missing required attack parameter: {e}") except ValueError: raise ValueError("Invalid value in attack_params. Ensure all values are integers.") - + super().__init__(engine, round_start, round_stop, attack_interval) self.layer_idx = int(attack_params["layer_idx"]) diff --git a/nebula/addons/gps/nebulagps.py b/nebula/addons/gps/nebulagps.py index 208f54dbf..8a310c561 100644 --- a/nebula/addons/gps/nebulagps.py +++ b/nebula/addons/gps/nebulagps.py @@ -26,7 +26,7 @@ def __init__(self, config, addr, update_interval: float = 5.0, verbose=False): self._verbose = verbose async def start(self): - """Inicia el servicio de GPS, enviando y recibiendo ubicaciones.""" + """Starts the GPS service, sending and receiving locations.""" logging.info("Starting NebulaGPS service...") self.running = True @@ -73,7 +73,7 @@ async def _send_location_loop(self): await asyncio.sleep(self.update_interval) async def _receive_location_loop(self): - """Escucha y almacena geolocalizaciones de otros nodos.""" + """Listens to and stores geolocations from other nodes.""" while self.running: try: data, addr = await asyncio.get_running_loop().run_in_executor( @@ -88,7 +88,7 @@ async def _receive_location_loop(self): if self._verbose: logging.info(f"Received GPS from {addr[0]}: {lat}, {lon}") except Exception as e: - logging.error(f"Error receiving GPS update: {e}") + logging.exception(f"Error receiving GPS update: {e}") async def _notify_geolocs(self): while True: @@ -102,5 +102,7 @@ async def _notify_geolocs(self): for addr, (lat, long) in geolocs.items(): dist = await self.calculate_distance(self_lat, self_long, lat, long) distances[addr] = (dist, (lat, long)) + + self._config.update_nodes_distance(distances) gpsevent = GPSEvent(distances) asyncio.create_task(EventManager.get_instance().publish_addonevent(gpsevent)) diff --git a/nebula/addons/mobility.py b/nebula/addons/mobility.py index 41fa6624d..522161e70 100755 --- a/nebula/addons/mobility.py +++ b/nebula/addons/mobility.py @@ -3,19 +3,17 @@ import math import random import time -from typing import TYPE_CHECKING +from functools import cached_property from nebula.addons.functions import print_msg_box from nebula.core.eventmanager import EventManager -from nebula.core.nebulaevents import GPSEvent +from nebula.core.nebulaevents import ChangeLocationEvent, GPSEvent +from nebula.core.network.communications import CommunicationsManager from nebula.core.utils.locker import Locker -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - class Mobility: - def __init__(self, config, cm: "CommunicationsManager", verbose=False): + def __init__(self, config, verbose=False): """ Initializes the mobility module with specified configuration and communication manager. @@ -52,7 +50,6 @@ def __init__(self, config, cm: "CommunicationsManager", verbose=False): """ logging.info("Starting mobility module...") self.config = config - self.cm = cm self.grace_time = self.config.participant["mobility_args"]["grace_time_mobility"] self.period = self.config.participant["mobility_args"]["change_geo_interval"] self.mobility = self.config.participant["mobility_args"]["mobility"] @@ -60,10 +57,10 @@ def __init__(self, config, cm: "CommunicationsManager", verbose=False): self.radius_federation = float(self.config.participant["mobility_args"]["radius_federation"]) self.scheme_mobility = self.config.participant["mobility_args"]["scheme_mobility"] self.round_frequency = int(self.config.participant["mobility_args"]["round_frequency"]) - # Protocol to change connections based on distance - self.max_distance_with_direct_connections = 300 # meters - self.max_movement_random_strategy = 100 # meters - self.max_movement_nearest_strategy = 100 # meters + # INFO: These values may change according to the needs of the federation + self.max_distance_with_direct_connections = 150 # meters + self.max_movement_random_strategy = 50 # meters + self.max_movement_nearest_strategy = 50 # meters self.max_initiate_approximation = self.max_distance_with_direct_connections * 1.2 # Logging box with mobility information mobility_msg = f"Mobility: {self.mobility}\nMobility type: {self.mobility_type}\nRadius federation: {self.radius_federation}\nScheme mobility: {self.scheme_mobility}\nEach {self.round_frequency} rounds" @@ -72,6 +69,10 @@ def __init__(self, config, cm: "CommunicationsManager", verbose=False): self._nodes_distances_lock = Locker("nodes_distances_lock", async_lock=True) self._verbose = verbose + @cached_property + def cm(self): + return CommunicationsManager.get_instance() + @property def round(self): """ @@ -101,6 +102,7 @@ async def start(self): `run_mobility` operation. """ await EventManager.get_instance().subscribe_addonevent(GPSEvent, self.update_nodes_distances) + await EventManager.get_instance().subscribe_addonevent(GPSEvent, self.update_nodes_distances) task = asyncio.create_task(self.run_mobility()) return task @@ -198,7 +200,8 @@ async def change_geo_location_nearest_neighbor_strategy( coordinates to determine the direction of movement. - The conversion from meters to degrees is based on approximate geographical conversion factors. """ - logging.info("πŸ“ Changing geo location towards the nearest neighbor") + if self._verbose: + logging.info("πŸ“ Changing geo location towards the nearest neighbor") scale_factor = min(1, self.max_movement_nearest_strategy / distance) # Calcular el Γ‘ngulo hacia el vecino angle = math.atan2(neighbor_longitude - longitude, neighbor_latitude - latitude) @@ -245,6 +248,8 @@ async def set_geo_location(self, latitude, longitude): self.config.participant["mobility_args"]["longitude"] = longitude if self._verbose: logging.info(f"πŸ“ New geo location: {latitude}, {longitude}") + cle = ChangeLocationEvent(latitude, longitude) + asyncio.create_task(EventManager.get_instance().publish_addonevent(cle)) async def change_geo_location(self): """ @@ -285,7 +290,8 @@ async def change_geo_location(self): addr, dist, (lat, long) = selected_neighbor if dist > self.max_initiate_approximation: # If the distance is too big, we move towards the neighbor - logging.info(f"Moving towards nearest neighbor: {addr}") + if self._verbose: + logging.info(f"Moving towards nearest neighbor: {addr}") await self.change_geo_location_nearest_neighbor_strategy( dist, latitude, diff --git a/nebula/addons/networksimulation/nebulanetworksimulator.py b/nebula/addons/networksimulation/nebulanetworksimulator.py index c78626603..22010e872 100644 --- a/nebula/addons/networksimulation/nebulanetworksimulator.py +++ b/nebula/addons/networksimulation/nebulanetworksimulator.py @@ -1,16 +1,14 @@ import asyncio import logging import subprocess -from typing import TYPE_CHECKING +from functools import cached_property from nebula.addons.networksimulation.networksimulator import NetworkSimulator from nebula.core.eventmanager import EventManager from nebula.core.nebulaevents import GPSEvent +from nebula.core.network.communications import CommunicationsManager from nebula.core.utils.locker import Locker -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - class NebulaNS(NetworkSimulator): NETWORK_CONDITIONS = { @@ -21,8 +19,7 @@ class NebulaNS(NetworkSimulator): } IP_MULTICAST = "239.255.255.250" - def __init__(self, communication_manager: "CommunicationsManager", changing_interval, interface, verbose=False): - self._cm = communication_manager + def __init__(self, changing_interval, interface, verbose=False): self._refresh_interval = changing_interval self._node_interface = interface self._verbose = verbose @@ -31,9 +28,16 @@ def __init__(self, communication_manager: "CommunicationsManager", changing_inte self._current_network_conditions = {} self._running = False + @cached_property + def cm(self): + return CommunicationsManager.get_instance() + async def start(self): logging.info("🌐 Nebula Network Simulator starting...") self._running = True + grace_time = self.cm.config.participant["mobility_args"]["grace_time_mobility"] + # if self._verbose: logging.info(f"Waiting {grace_time}s to start applying network conditions based on distances between devices") + # await asyncio.sleep(grace_time) await EventManager.get_instance().subscribe_addonevent( GPSEvent, self._change_network_conditions_based_on_distances ) @@ -213,7 +217,7 @@ def extract_number(value): match = re.match(r"([\d.]+)", value) if not match: - raise ValueError(f"Invalid format: {value}") + raise ValueError(f"Formato invΓ‘lido: {value}") return float(match.group(1)) if self._verbose: @@ -224,11 +228,11 @@ def extract_number(value): thresholds = sorted(th.keys()) - # If the distance is less than the first threshold, return the best condition + # Si la distancia es menor que el primer umbral, devolver la mejor condiciΓ³n if distance < thresholds[0]: conditions = {"bandwidth": th[thresholds[0]]["bandwidth"], "delay": th[thresholds[0]]["delay"]} - # Find the section in which the distance is located. + # Encontrar el tramo en el que se encuentra la distancia for i in range(len(thresholds) - 1): lower_bound = thresholds[i] upper_bound = thresholds[i + 1] @@ -241,7 +245,7 @@ def extract_number(value): lower_cond = th[lower_bound] upper_cond = th[upper_bound] - # Extract numerical values and units + # Extraer valores numΓ©ricos y unidades lower_bandwidth_value = extract_number(lower_cond["bandwidth"]) upper_bandwidth_value = extract_number(upper_cond["bandwidth"]) lower_bandwidth_unit = lower_cond["bandwidth"].replace(str(lower_bandwidth_value), "") @@ -251,22 +255,22 @@ def extract_number(value): upper_delay_value = extract_number(upper_cond["delay"]) delay_unit = lower_cond["delay"].replace(str(lower_delay_value), "") - # Calculate progress in the leg (0 to 1) + # Calcular el progreso en el tramo (0 a 1) progress = (distance - lower_bound) / (upper_bound - lower_bound) if self._verbose: logging.info(f"Progress between the bounds: {progress}") - # Linear interpolation of values + # InterpolaciΓ³n lineal de valores bandwidth_value = lower_bandwidth_value - progress * (lower_bandwidth_value - upper_bandwidth_value) delay_value = lower_delay_value + progress * (upper_delay_value - lower_delay_value) - # Reconstruct values with original units + # Reconstruir valores con unidades originales bandwidth = f"{round(bandwidth_value, 2)}{lower_bandwidth_unit}" delay = f"{round(delay_value, 2)}{delay_unit}" conditions = {"bandwidth": bandwidth, "delay": delay} - # If the distance is infinite, return the last value + # Si la distancia es infinita, devolver el ΓΊltimo valor if not conditions: conditions = {"bandwidth": th[float("inf")]["bandwidth"], "delay": th[float("inf")]["delay"]} if self._verbose: diff --git a/nebula/addons/networksimulation/networksimulator.py b/nebula/addons/networksimulation/networksimulator.py index 16bd22409..c7d70b7cb 100644 --- a/nebula/addons/networksimulation/networksimulator.py +++ b/nebula/addons/networksimulation/networksimulator.py @@ -27,9 +27,7 @@ class NetworkSimulatorException(Exception): pass -def factory_network_simulator( - net_sim, communication_manager, changing_interval, interface, verbose -) -> NetworkSimulator: +def factory_network_simulator(net_sim, changing_interval, interface, verbose) -> NetworkSimulator: from nebula.addons.networksimulation.nebulanetworksimulator import NebulaNS SIMULATION_SERVICES = { @@ -39,6 +37,6 @@ def factory_network_simulator( net_serv = SIMULATION_SERVICES.get(net_sim, NebulaNS) if net_serv: - return net_serv(communication_manager, changing_interval, interface, verbose) + return net_serv(changing_interval, interface, verbose) else: raise NetworkSimulatorException(f"Network Simulator {net_sim} not found") diff --git a/nebula/addons/reporter.py b/nebula/addons/reporter.py index cc6215b79..0a8e1426c 100755 --- a/nebula/addons/reporter.py +++ b/nebula/addons/reporter.py @@ -10,11 +10,11 @@ import psutil if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager + pass class Reporter: - def __init__(self, config, trainer, cm: "CommunicationsManager"): + def __init__(self, config, trainer): """ Initializes the reporter module for sending periodic updates to a dashboard controller. @@ -48,13 +48,13 @@ def __init__(self, config, trainer, cm: "CommunicationsManager"): - Initializes both current and accumulated metrics for traffic monitoring. """ logging.info("Starting reporter module") + self._cm = None self.config = config self.trainer = trainer - self.cm = cm self.frequency = self.config.participant["reporter_args"]["report_frequency"] self.grace_time = self.config.participant["reporter_args"]["grace_time_reporter"] self.data_queue = asyncio.Queue() - self.url = f"http://{self.config.participant['scenario_args']['controller']}/platform/dashboard/{self.config.participant['scenario_args']['name']}/node/update" + self.url = f"http://{self.config.participant['scenario_args']['controller']}/nodes/{self.config.participant['scenario_args']['name']}/update" self.counter = 0 self.first_net_metrics = True @@ -68,6 +68,16 @@ def __init__(self, config, trainer, cm: "CommunicationsManager"): self.acc_packets_sent = 0 self.acc_packets_recv = 0 + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + async def enqueue_data(self, name, value): """ Asynchronously enqueues data for reporting. @@ -157,7 +167,7 @@ async def report_scenario_finished(self): might be temporarily overloaded. - Logs exceptions if the connection attempt to the controller fails. """ - url = f"http://{self.config.participant['scenario_args']['controller']}/platform/dashboard/{self.config.participant['scenario_args']['name']}/node/done" + url = f"http://{self.config.participant['scenario_args']['controller']}/nodes/{self.config.participant['scenario_args']['name']}/done" data = json.dumps({"idx": self.config.participant["device_args"]["idx"]}) headers = { "Content-Type": "application/json", diff --git a/nebula/addons/reputation/reputation.py b/nebula/addons/reputation/reputation.py index 38b86fcfc..58af42c04 100644 --- a/nebula/addons/reputation/reputation.py +++ b/nebula/addons/reputation/reputation.py @@ -1,17 +1,15 @@ -import csv import logging -import os import random -import torch -import numpy as np import time +from datetime import datetime +from typing import TYPE_CHECKING + import numpy as np +import torch -from typing import TYPE_CHECKING from nebula.addons.functions import print_msg_box -from nebula.core.nebulaevents import RoundStartEvent, UpdateReceivedEvent, MessageEvent, AggregationEvent from nebula.core.eventmanager import EventManager -from datetime import datetime +from nebula.core.nebulaevents import AggregationEvent, RoundStartEvent, UpdateReceivedEvent from nebula.core.utils.helper import ( cosine_metric, euclidean_metric, @@ -22,8 +20,9 @@ ) if TYPE_CHECKING: - from nebula.core.engine import Engine from nebula.config.config import Config + from nebula.core.engine import Engine + class Metrics: def __init__( @@ -47,15 +46,11 @@ def __init__( self.fraction_of_params_changed = { "fraction_changed": fraction_changed, "threshold": threshold, - "round": num_round - } - - self.model_arrival_latency = { - "latency": latency, "round": num_round, - "round_received": current_round } + self.model_arrival_latency = {"latency": latency, "round": num_round, "round_received": current_round} + self.messages = [] self.similarity = [] @@ -64,10 +59,11 @@ def __init__( class Reputation: """ Class to define and manage the reputation of a participant in the network. - + The class handles collection of metrics, calculation of static and dynamic reputation, updating history, and communication of reputation scores to neighbors. """ + def __init__(self, engine: "Engine", config: "Config"): """ Initialize the Reputation system. @@ -102,21 +98,25 @@ def __init__(self, engine: "Engine", config: "Config"): self._log_dir = engine.log_dir self._idx = engine.idx self.connection_metrics = [] - + neighbors: str = self._config.participant["network_args"]["neighbors"] self.connection_metrics = {} for nei in neighbors.split(): self.connection_metrics[f"{nei}"] = Metrics() - + self._with_reputation = self._config.participant["defense_args"]["with_reputation"] self._reputation_metrics = self._config.participant["defense_args"]["reputation_metrics"] self._initial_reputation = float(self._config.participant["defense_args"]["initial_reputation"]) self._weighting_factor = self._config.participant["defense_args"]["weighting_factor"] - self._weight_model_arrival_latency = float(self._config.participant["defense_args"]["weight_model_arrival_latency"]) + self._weight_model_arrival_latency = float( + self._config.participant["defense_args"]["weight_model_arrival_latency"] + ) self._weight_model_similarity = float(self._config.participant["defense_args"]["weight_model_similarity"]) self._weight_num_messages = float(self._config.participant["defense_args"]["weight_num_messages"]) - self._weight_fraction_params_changed = float(self._config.participant["defense_args"]["weight_fraction_params_changed"]) - + self._weight_fraction_params_changed = float( + self._config.participant["defense_args"]["weight_fraction_params_changed"] + ) + msg = f"Reputation system: {self._with_reputation}" msg += f"\nReputation metrics: {self._reputation_metrics}" msg += f"\nInitial reputation: {self._initial_reputation}" @@ -127,12 +127,12 @@ def __init__(self, engine: "Engine", config: "Config"): msg += f"\nWeight number of messages: {self._weight_num_messages}" msg += f"\nWeight fraction of parameters changed: {self._weight_fraction_params_changed}" print_msg_box(msg=msg, indent=2, title="Defense information") - + @property def engine(self): """Return the engine instance.""" return self._engine - + def save_data( self, type_data, @@ -195,17 +195,19 @@ def save_data( self.connection_metrics[nei].messages = [] self.connection_metrics[nei].messages.append(combined_data["number_message"]) elif type_data == "fraction_of_params_changed": - self.connection_metrics[nei].fraction_of_params_changed.update(combined_data["fraction_of_params_changed"]) + self.connection_metrics[nei].fraction_of_params_changed.update( + combined_data["fraction_of_params_changed"] + ) elif type_data == "model_arrival_latency": self.connection_metrics[nei].model_arrival_latency.update(combined_data["model_arrival_latency"]) except Exception: logging.exception("Error saving data") - + async def setup(self): """ Setup the reputation system by subscribing to various events. - + This function enables the reputation system and subscribes to events based on active metrics. """ if self._with_reputation: @@ -215,17 +217,25 @@ async def setup(self): if self._reputation_metrics.get("model_similarity", False): await EventManager.get_instance().subscribe_node_event(UpdateReceivedEvent, self.recollect_similarity) if self._reputation_metrics.get("fraction_parameters_changed", False): - await EventManager.get_instance().subscribe_node_event(UpdateReceivedEvent, self.recollect_fraction_of_parameters_changed) + await EventManager.get_instance().subscribe_node_event( + UpdateReceivedEvent, self.recollect_fraction_of_parameters_changed + ) if self._reputation_metrics.get("num_messages", False): await EventManager.get_instance().subscribe(("model", "update"), self.recollect_number_message) await EventManager.get_instance().subscribe(("model", "initialization"), self.recollect_number_message) await EventManager.get_instance().subscribe(("control", "alive"), self.recollect_number_message) - await EventManager.get_instance().subscribe(("federation", "federation_models_included"), self.recollect_number_message) + await EventManager.get_instance().subscribe( + ("federation", "federation_models_included"), self.recollect_number_message + ) await EventManager.get_instance().subscribe(("reputation", "share"), self.recollect_number_message) if self._reputation_metrics.get("model_arrival_latency", False): - await EventManager.get_instance().subscribe_node_event(UpdateReceivedEvent, self.recollect_model_arrival_latency) - - def init_reputation(self, addr, federation_nodes=None, round_num=None, last_feedback_round=None, init_reputation=None): + await EventManager.get_instance().subscribe_node_event( + UpdateReceivedEvent, self.recollect_model_arrival_latency + ) + + def init_reputation( + self, addr, federation_nodes=None, round_num=None, last_feedback_round=None, init_reputation=None + ): """ Initialize the reputation for each federation node. @@ -272,13 +282,24 @@ def is_valid_ip(self, federation_nodes): list: A list of valid IP addresses. """ valid_ip = [] - for i in federation_nodes: + for i in federation_nodes: valid_ip.append(i) return valid_ip - def _calculate_static_reputation(self, addr, nei, metric_messages_number, metric_similarity, metric_fraction, metric_model_arrival_latency, - weight_messages_number, weight_similarity, weight_fraction, weight_model_arrival_latency): + def _calculate_static_reputation( + self, + addr, + nei, + metric_messages_number, + metric_similarity, + metric_fraction, + metric_model_arrival_latency, + weight_messages_number, + weight_similarity, + weight_fraction, + weight_model_arrival_latency, + ): """ Calculate the static reputation of a participant using fixed weights. @@ -343,7 +364,8 @@ async def _calculate_dynamic_reputation(self, addr, neighbors): for metric_name in self.history_data.keys(): if self._reputation_metrics.get(metric_name, False): valid_entries = [ - entry for entry in self.history_data[metric_name] + entry + for entry in self.history_data[metric_name] if entry["round"] >= self._engine.get_round() and entry.get("weight") not in [None, -1] ] @@ -358,16 +380,21 @@ async def _calculate_dynamic_reputation(self, addr, neighbors): for metric_name in self.history_data.keys(): if self._reputation_metrics.get(metric_name, False): for entry in self.history_data.get(metric_name, []): - if entry["round"] == self._engine.get_round() and entry["metric_name"] == metric_name and entry["nei"] == nei: + if ( + entry["round"] == self._engine.get_round() + and entry["metric_name"] == metric_name + and entry["nei"] == nei + ): metric_values[metric_name] = entry["metric_value"] break if all(metric_name in metric_values for metric_name in average_weights): reputation_with_weights = sum( - metric_values.get(metric_name, 0) * average_weights[metric_name] - for metric_name in average_weights + metric_values.get(metric_name, 0) * average_weights[metric_name] for metric_name in average_weights + ) + logging.info( + f"Dynamic reputation with weights for {nei} at round {self.engine.get_round()}: {reputation_with_weights}" ) - logging.info(f"Dynamic reputation with weights for {nei} at round {self.engine.get_round()}: {reputation_with_weights}") avg_reputation = self.save_reputation_history_in_memory(self.engine.addr, nei, reputation_with_weights) @@ -406,7 +433,7 @@ def _update_reputation_record(self, nei, reputation, data): if self.reputation[nei]["reputation"] < 0.6: self.rejected_nodes.add(nei) logging.info(f"Rejected node {nei} at round {self._engine.get_round()}") - + def calculate_weighted_values( self, avg_messages_number_message_normalized, @@ -417,7 +444,7 @@ def calculate_weighted_values( current_round, addr, nei, - reputation_metrics + reputation_metrics, ): """ Calculate the weighted values for each metric based on current measurements and historical data. @@ -434,7 +461,6 @@ def calculate_weighted_values( reputation_metrics (dict): Dictionary indicating which metrics are active. """ if current_round is not None: - normalized_weights = {} required_keys = [ "num_messages", @@ -451,7 +477,7 @@ def calculate_weighted_values( "num_messages": avg_messages_number_message_normalized, "model_similarity": similarity_reputation, "fraction_parameters_changed": fraction_score_asign, - "model_arrival_latency": avg_model_arrival_latency, + "model_arrival_latency": avg_model_arrival_latency, } active_metrics = {k: v for k, v in metrics.items() if reputation_metrics.get(k, False)} @@ -464,7 +490,7 @@ def calculate_weighted_values( "nei": nei, "metric_name": metric_name, "metric_value": current_value, - "weight": None + "weight": None, }) adjusted_weights = {} @@ -474,7 +500,11 @@ def calculate_weighted_values( for metric_name, current_value in active_metrics.items(): historical_values = history_data[metric_name] - metric_values = [entry['metric_value'] for entry in historical_values if 'metric_value' in entry and entry["metric_value"] != 0] + metric_values = [ + entry["metric_value"] + for entry in historical_values + if "metric_value" in entry and entry["metric_value"] != 0 + ] if metric_values: mean_value = np.mean(metric_values) @@ -487,7 +517,10 @@ def calculate_weighted_values( if all(deviation == 0.0 for deviation in desviations.values()): random_weights = [random.random() for _ in range(num_active_metrics)] total_random_weight = sum(random_weights) - normalized_weights = {metric_name: weight / total_random_weight for metric_name, weight in zip(active_metrics, random_weights)} + normalized_weights = { + metric_name: weight / total_random_weight + for metric_name, weight in zip(active_metrics, random_weights, strict=False) + } else: max_desviation = max(desviations.values()) if desviations else 1 normalized_weights = { @@ -503,7 +536,7 @@ def calculate_weighted_values( normalized_weights = {metric_name: 1 / num_active_metrics for metric_name in active_metrics} mean_deviation = np.mean(list(desviations.values())) - dynamic_min_weight = max(0.1, mean_deviation / (mean_deviation + 1)) + dynamic_min_weight = max(0.1, mean_deviation / (mean_deviation + 1)) total_adjusted_weight = 0 @@ -562,10 +595,17 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act metrics_instance = self.connection_metrics.get(nei) if not metrics_instance: logging.warning(f"No metrics found for neighbor {nei}") - return avg_messages_number_message_normalized, similarity_reputation, fraction_score_asign, avg_model_arrival_latency + return ( + avg_messages_number_message_normalized, + similarity_reputation, + fraction_score_asign, + avg_model_arrival_latency, + ) if metrics_active.get("num_messages", False): - filtered_messages = [msg for msg in metrics_instance.messages if msg.get("current_round") == current_round] + filtered_messages = [ + msg for msg in metrics_instance.messages if msg.get("current_round") == current_round + ] for msg in filtered_messages: self.messages_number_message.append({ "number_message": msg.get("time"), @@ -580,7 +620,9 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act addr, nei, messages_number_message_normalized, current_round ) if avg_messages_number_message_normalized is None and current_round > 4: - avg_messages_number_message_normalized = self.number_message_history[(addr, nei)][current_round - 1]["avg_number_message"] + avg_messages_number_message_normalized = self.number_message_history[(addr, nei)][ + current_round - 1 + ]["avg_number_message"] if metrics_active.get("fraction_parameters_changed", False): if metrics_instance.fraction_of_params_changed.get("round") == current_round: @@ -600,11 +642,7 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act round_latency = metrics_instance.model_arrival_latency.get("round") latency = metrics_instance.model_arrival_latency.get("latency") messages_model_arrival_latency_normalized = self.manage_model_arrival_latency( - round_latency, - addr, - nei, - latency, - current_round + round_latency, addr, nei, latency, current_round ) if current_round >= 5 and metrics_active.get("model_similarity", False): @@ -617,7 +655,9 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act addr, nei, messages_model_arrival_latency_normalized, current_round ) if avg_model_arrival_latency is None and current_round > 4: - avg_model_arrival_latency = self.model_arrival_latency_history[(addr, nei)][current_round - 1]["score"] + avg_model_arrival_latency = self.model_arrival_latency_history[(addr, nei)][current_round - 1][ + "score" + ] if self.messages_number_message is not None: messages_number_message_normalized, messages_number_message_count = self.manage_metric_number_message( @@ -627,7 +667,9 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act addr, nei, messages_number_message_normalized, current_round ) if avg_messages_number_message_normalized is None and current_round > 4: - avg_messages_number_message_normalized = self.number_message_history[(addr, nei)][current_round - 1]["avg_number_message"] + avg_messages_number_message_normalized = self.number_message_history[(addr, nei)][ + current_round - 1 + ]["avg_number_message"] if current_round >= 5: if fraction_score_normalized > 0: @@ -640,10 +682,14 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act if fraction_previous_round is not None: fraction_score_asign = fraction_score_normalized * 0.8 + fraction_previous_round * 0.2 - self.fraction_changed_history[(addr, nei, current_round)]["fraction_score"] = fraction_score_asign + self.fraction_changed_history[(addr, nei, current_round)]["fraction_score"] = ( + fraction_score_asign + ) else: fraction_score_asign = fraction_score_normalized - self.fraction_changed_history[(addr, nei, current_round)]["fraction_score"] = fraction_score_asign + self.fraction_changed_history[(addr, nei, current_round)]["fraction_score"] = ( + fraction_score_asign + ) else: fraction_previous_round = None key_previous_round = (addr, nei, current_round - 1) if current_round - 1 > 0 else None @@ -665,7 +711,7 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act if fraction_neighbors_scores: fraction_score_asign = np.mean(list(fraction_neighbors_scores.values())) else: - fraction_score_asign = 0 + fraction_score_asign = 0 else: fraction_score_asign = 0 @@ -681,7 +727,12 @@ async def calculate_value_metrics(self, log_dir, id_node, addr, nei, metrics_act self.engine.total_rounds, ) - return avg_messages_number_message_normalized, similarity_reputation, fraction_score_asign, avg_model_arrival_latency + return ( + avg_messages_number_message_normalized, + similarity_reputation, + fraction_score_asign, + avg_model_arrival_latency, + ) except Exception as e: logging.exception(f"Error calculating reputation. Type: {type(e).__name__}") @@ -714,7 +765,9 @@ def create_graphics_to_metrics( """ if current_round is not None and current_round < total_rounds: model_arrival_latency_dict = {f"R-Model_arrival_latency_reputation/{addr}": {nei: model_arrival_latency}} - messages_number_message_count_dict = {f"R-Count_messages_number_message_reputation/{addr}": {nei: number_message_count}} + messages_number_message_count_dict = { + f"R-Count_messages_number_message_reputation/{addr}": {nei: number_message_count} + } messages_number_message_norm_dict = {f"R-number_message_reputation/{addr}": {nei: number_message_norm}} similarity_dict = {f"R-Similarity_reputation/{addr}": {nei: similarity}} fraction_dict = {f"R-Fraction_reputation/{addr}": {nei: fraction}} @@ -815,9 +868,7 @@ def analyze_anomalies( for i in range(0, round_num + 1): potential_prev_key = (addr, nei, round_num - i) if potential_prev_key in self.fraction_changed_history: - mean_fraction_prev = self.fraction_changed_history[potential_prev_key][ - "mean_fraction" - ] + mean_fraction_prev = self.fraction_changed_history[potential_prev_key]["mean_fraction"] if mean_fraction_prev is not None: prev_key = potential_prev_key break @@ -840,8 +891,16 @@ def analyze_anomalies( self.fraction_changed_history[key]["fraction_anomaly"] = fraction_anomaly self.fraction_changed_history[key]["threshold_anomaly"] = threshold_anomaly - penalization_factor_fraction = abs(current_fraction - mean_fraction_prev) / mean_fraction_prev if mean_fraction_prev != 0 else 1 - penalization_factor_threshold = abs(current_threshold - mean_threshold_prev) / mean_threshold_prev if mean_threshold_prev != 0 else 1 + penalization_factor_fraction = ( + abs(current_fraction - mean_fraction_prev) / mean_fraction_prev + if mean_fraction_prev != 0 + else 1 + ) + penalization_factor_threshold = ( + abs(current_threshold - mean_threshold_prev) / mean_threshold_prev + if mean_threshold_prev != 0 + else 1 + ) k_fraction = penalization_factor_fraction if penalization_factor_fraction != 0 else 1 k_threshold = penalization_factor_threshold if penalization_factor_threshold != 0 else 1 @@ -871,16 +930,20 @@ def analyze_anomalies( if current_threshold is not None and mean_threshold_prev is not None else 0 ) - + fraction_weight = 0.5 threshold_weight = 0.5 fraction_score = fraction_weight * fraction_value + threshold_weight * threshold_value self.fraction_changed_history[key]["mean_fraction"] = (current_fraction + mean_fraction_prev) / 2 - self.fraction_changed_history[key]["std_dev_fraction"] = np.sqrt(((current_fraction - mean_fraction_prev) ** 2 + std_dev_fraction_prev**2) / 2) + self.fraction_changed_history[key]["std_dev_fraction"] = np.sqrt( + ((current_fraction - mean_fraction_prev) ** 2 + std_dev_fraction_prev**2) / 2 + ) self.fraction_changed_history[key]["mean_threshold"] = (current_threshold + mean_threshold_prev) / 2 - self.fraction_changed_history[key]["std_dev_threshold"] = np.sqrt(((0.1 * (current_threshold - mean_threshold_prev) ** 2) + std_dev_threshold_prev**2) / 2) + self.fraction_changed_history[key]["std_dev_threshold"] = np.sqrt( + ((0.1 * (current_threshold - mean_threshold_prev) ** 2) + std_dev_threshold_prev**2) / 2 + ) return max(fraction_score, 0) else: @@ -888,10 +951,8 @@ def analyze_anomalies( except Exception: logging.exception("Error analyzing anomalies") return -1 - - def manage_model_arrival_latency( - self, round_num, addr, nei, latency, current_round - ): + + def manage_model_arrival_latency(self, round_num, addr, nei, latency, current_round): """ Manage the model arrival latency metric and normalize it based on historical latencies. @@ -1021,7 +1082,7 @@ def save_model_arrival_latency_history(self, addr, nei, model_arrival_latency, r return avg_model_arrival_latency except Exception: logging.exception("Error saving model_arrival_latency history") - + def manage_metric_number_message(self, messages_number_message, addr, nei, current_round, metric_active=True): """ Manage and normalize the number of messages metric using percentiles. @@ -1042,7 +1103,7 @@ def manage_metric_number_message(self, messages_number_message, addr, nei, curre if not metric_active: return 0.0, 0 - + previous_round = current_round current_addr_nei = (addr, nei) @@ -1093,7 +1154,7 @@ def manage_metric_number_message(self, messages_number_message, addr, nei, curre except Exception: logging.exception("Error managing metric number_message") return 0.0, 0 - + def save_number_message_history(self, addr, nei, messages_number_message_normalized, current_round): """ Save the normalized number_message history in memory and calculate a weighted average. @@ -1137,7 +1198,7 @@ def save_number_message_history(self, addr, nei, messages_number_message_normali except Exception as e: logging.exception(f"Error managing model_arrival_latency latency: {e}") return 0.0 - + def save_reputation_history_in_memory(self, addr, nei, reputation): """ Save the reputation history for a neighbor and compute an average reputation. @@ -1236,10 +1297,10 @@ def calculate_similarity_from_metrics(self, nei, current_round): pearson_correlation = float(metric.get("pearson_correlation", 0)) similarity_value = ( - weight_cosine * cosine + - weight_euclidean * euclidean + - weight_manhattan * manhattan + - weight_pearson * pearson_correlation + weight_cosine * cosine + + weight_euclidean * euclidean + + weight_manhattan * manhattan + + weight_pearson * pearson_correlation ) return similarity_value @@ -1264,16 +1325,19 @@ async def calculate_reputation(self, ae: AggregationEvent): history_data = self.history_data for nei in neighbors: - metric_messages_number, metric_similarity, metric_fraction, metric_model_arrival_latency = ( - await self.calculate_value_metrics( - self._log_dir, - self._idx, - self._addr, - nei, - metrics_active=self._reputation_metrics, - ) + ( + metric_messages_number, + metric_similarity, + metric_fraction, + metric_model_arrival_latency, + ) = await self.calculate_value_metrics( + self._log_dir, + self._idx, + self._addr, + nei, + metrics_active=self._reputation_metrics, ) - + if self._weighting_factor == "dynamic": self.calculate_weighted_values( metric_messages_number, @@ -1300,7 +1364,7 @@ async def calculate_reputation(self, ae: AggregationEvent): self._weight_fraction_params_changed, self._weight_model_arrival_latency, ) - + if self._weighting_factor == "dynamic" and self._engine.get_round() >= 5: await self._calculate_dynamic_reputation(self._addr, neighbors) @@ -1342,13 +1406,17 @@ async def send_reputation_to_neighbors(self, neighbors): for neighbor in neighbors_to_send: message = self._engine.cm.create_message( - "reputation", "share", node_id=nei, score=float(data["reputation"]), round=self._engine.get_round() + "reputation", + "share", + node_id=nei, + score=float(data["reputation"]), + round=self._engine.get_round(), ) await self._engine.cm.send_message(neighbor, message) logging.info( f"Sending reputation to node {nei} from node {neighbor} with reputation {data['reputation']}" ) - + def create_graphic_reputation(self, addr, round_num): """ Create a graphical representation of the reputation scores and log the data. @@ -1371,7 +1439,7 @@ def create_graphic_reputation(self, addr, round_num): except Exception: logging.exception("Error creating reputation graphic") - + async def update_process_aggregation(self, updates): """ Update the aggregation process by removing nodes that have been rejected. @@ -1402,7 +1470,7 @@ async def include_feedback_in_reputation(self): if self.reputation_with_all_feedback is None: logging.info("No feedback received.") return False - + updated = False for (current_node, node_ip, round_num), scores in self.reputation_with_all_feedback.items(): @@ -1414,7 +1482,10 @@ async def include_feedback_in_reputation(self): logging.info(f"No reputation for node {node_ip}") continue - if "last_feedback_round" in self.reputation[node_ip] and self.reputation[node_ip]["last_feedback_round"] >= round_num: + if ( + "last_feedback_round" in self.reputation[node_ip] + and self.reputation[node_ip]["last_feedback_round"] >= round_num + ): continue avg_feedback = sum(scores) / len(scores) @@ -1440,7 +1511,7 @@ async def include_feedback_in_reputation(self): return True else: return False - + async def on_round_start(self, rse: RoundStartEvent): """ Event handler for the start of a round. It stores the start time and updates the expected nodes. @@ -1467,7 +1538,7 @@ async def recollect_model_arrival_latency(self, ure: UpdateReceivedEvent): if current_round not in self.round_timing_info: self.round_timing_info[current_round] = {} - + if "model_received_time" not in self.round_timing_info[current_round]: self.round_timing_info[current_round]["model_received_time"] = {} diff --git a/nebula/config/config.py b/nebula/config/config.py index 20df01016..4b0b8df3a 100755 --- a/nebula/config/config.py +++ b/nebula/config/config.py @@ -184,6 +184,9 @@ def add_neighbor_from_config(self, addr): self.participant["network_args"]["neighbors"] += " " + addr self.participant["mobility_args"]["neighbors_distance"][addr] = None + def update_nodes_distance(self, distances: dict): + self.participant["mobility_args"]["neighbors_distance"] = {node: dist for node, (dist, _) in distances.items()} + def update_neighbors_from_config(self, current_connections, dest_addr): final_neighbors = [] for n in current_connections: diff --git a/nebula/controller.py b/nebula/controller.py deleted file mode 100755 index 5c266b56c..000000000 --- a/nebula/controller.py +++ /dev/null @@ -1,738 +0,0 @@ -import asyncio -import importlib -import json -import logging -import os -import re -import signal -import subprocess -import sys -import threading -import time - -import docker -import psutil -import uvicorn -from dotenv import load_dotenv -from fastapi import FastAPI -from watchdog.events import PatternMatchingEventHandler -from watchdog.observers import Observer - -from nebula.addons.env import check_environment -from nebula.config.config import Config -from nebula.config.mender import Mender -from nebula.scenarios import ScenarioManagement -from nebula.tests import main as deploy_tests -from nebula.utils import DockerUtils, SocketUtils - - -# Setup controller logger -class TermEscapeCodeFormatter(logging.Formatter): - def __init__(self, fmt=None, datefmt=None, style="%", validate=True): - super().__init__(fmt, datefmt, style, validate) - - def format(self, record): - escape_re = re.compile(r"\x1b\[[0-9;]*m") - record.msg = re.sub(escape_re, "", str(record.msg)) - return super().format(record) - - -# Initialize FastAPI app outside the Controller class -app = FastAPI() - - -# Define endpoints outside the Controller class -@app.get("/") -async def read_root(): - return {"message": "Welcome to the NEBULA Controller API"} - - -@app.get("/status") -async def get_status(): - return {"status": "NEBULA Controller API is running"} - - -@app.get("/resources") -async def get_resources(): - devices = 0 - gpu_memory_percent = [] - - # Obtain available RAM - memory_info = await asyncio.to_thread(psutil.virtual_memory) - - if importlib.util.find_spec("pynvml") is not None: - try: - import pynvml - - await asyncio.to_thread(pynvml.nvmlInit) - devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) - - # Obtain GPU info - for i in range(devices): - handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) - memory_info_gpu = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) - memory_used_percent = (memory_info_gpu.used / memory_info_gpu.total) * 100 - gpu_memory_percent.append(memory_used_percent) - - except Exception: # noqa: S110 - pass - - return { - # "cpu_percent": psutil.cpu_percent(), - "gpus": devices, - "memory_percent": memory_info.percent, - "gpu_memory_percent": gpu_memory_percent, - } - - -@app.get("/least_memory_gpu") -async def get_least_memory_gpu(): - gpu_with_least_memory_index = None - - if importlib.util.find_spec("pynvml") is not None: - max_memory_used_percent = 50 - try: - import pynvml - - await asyncio.to_thread(pynvml.nvmlInit) - devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) - - # Obtain GPU info - for i in range(devices): - handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) - memory_info = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) - memory_used_percent = (memory_info.used / memory_info.total) * 100 - - # Obtain GPU with less memory available - if memory_used_percent > max_memory_used_percent: - max_memory_used_percent = memory_used_percent - gpu_with_least_memory_index = i - - except Exception: # noqa: S110 - pass - - return { - "gpu_with_least_memory_index": gpu_with_least_memory_index, - } - - -@app.get("/available_gpus/") -async def get_available_gpu(): - available_gpus = [] - - if importlib.util.find_spec("pynvml") is not None: - try: - import pynvml - - await asyncio.to_thread(pynvml.nvmlInit) - devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) - - # Obtain GPU info - for i in range(devices): - handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) - memory_info = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) - memory_used_percent = (memory_info.used / memory_info.total) * 100 - - # Obtain available GPUs - if memory_used_percent < 5: - available_gpus.append(i) - - return { - "available_gpus": available_gpus, - } - except Exception: # noqa: S110 - pass - - -class NebulaEventHandler(PatternMatchingEventHandler): - """ - NebulaEventHandler handles file system events for .sh scripts. - - This class monitors the creation, modification, and deletion of .sh scripts - in a specified directory. - """ - - patterns = ["*.sh", "*.ps1"] - - def __init__(self): - super(NebulaEventHandler, self).__init__() - self.last_processed = {} - self.timeout_ns = 5 * 1e9 - self.processing_files = set() - self.lock = threading.Lock() - - def _should_process_event(self, src_path: str) -> bool: - current_time_ns = time.time_ns() - logging.info(f"Current time (ns): {current_time_ns}") - with self.lock: - if src_path in self.last_processed: - logging.info(f"Last processed time for {src_path}: {self.last_processed[src_path]}") - last_time = self.last_processed[src_path] - if current_time_ns - last_time < self.timeout_ns: - return False - self.last_processed[src_path] = current_time_ns - return True - - def _is_being_processed(self, src_path: str) -> bool: - with self.lock: - if src_path in self.processing_files: - logging.info(f"Skipping {src_path} as it is already being processed.") - return True - self.processing_files.add(src_path) - return False - - def _processing_done(self, src_path: str): - with self.lock: - if src_path in self.processing_files: - self.processing_files.remove(src_path) - - def verify_nodes_ports(self, src_path): - parent_dir = os.path.dirname(src_path) - base_dir = os.path.basename(parent_dir) - scenario_path = os.path.join(os.path.dirname(parent_dir), base_dir) - - try: - port_mapping = {} - new_port_start = 50000 - - participant_files = sorted( - f for f in os.listdir(scenario_path) if f.endswith(".json") and f.startswith("participant") - ) - - for filename in participant_files: - file_path = os.path.join(scenario_path, filename) - with open(file_path) as json_file: - node = json.load(json_file) - current_port = node["network_args"]["port"] - port_mapping[current_port] = SocketUtils.find_free_port(start_port=new_port_start) - logging.info( - f"Participant file: {filename} | Current port: {current_port} | New port: {port_mapping[current_port]}" - ) - new_port_start = port_mapping[current_port] + 1 - - for filename in participant_files: - file_path = os.path.join(scenario_path, filename) - with open(file_path) as json_file: - node = json.load(json_file) - current_port = node["network_args"]["port"] - node["network_args"]["port"] = port_mapping[current_port] - neighbors = node["network_args"]["neighbors"] - - for old_port, new_port in port_mapping.items(): - neighbors = neighbors.replace(f":{old_port}", f":{new_port}") - - node["network_args"]["neighbors"] = neighbors - - with open(file_path, "w") as f: - json.dump(node, f, indent=4) - - except Exception as e: - print(f"Error processing JSON files: {e}") - - def on_created(self, event): - """ - Handles the event when a file is created. - """ - if event.is_directory: - return - src_path = event.src_path - if not self._should_process_event(src_path): - return - if self._is_being_processed(src_path): - return - logging.info("File created: %s" % src_path) - try: - self.verify_nodes_ports(src_path) - self.run_script(src_path) - finally: - self._processing_done(src_path) - - def on_deleted(self, event): - """ - Handles the event when a file is deleted. - """ - if event.is_directory: - return - src_path = event.src_path - if not self._should_process_event(src_path): - return - if self._is_being_processed(src_path): - return - logging.info("File deleted: %s" % src_path) - directory_script = os.path.dirname(src_path) - pids_file = os.path.join(directory_script, "current_scenario_pids.txt") - logging.info(f"Killing processes from {pids_file}") - try: - self.kill_script_processes(pids_file) - os.remove(pids_file) - except FileNotFoundError: - logging.warning(f"{pids_file} not found.") - except Exception as e: - logging.exception(f"Error while killing processes: {e}") - finally: - self._processing_done(src_path) - - def run_script(self, script): - try: - logging.info(f"Running script: {script}") - if script.endswith(".sh"): - result = subprocess.run(["bash", script], capture_output=True, text=True) - logging.info(f"Script output:\n{result.stdout}") - if result.stderr: - logging.error(f"Script error:\n{result.stderr}") - elif script.endswith(".ps1"): - subprocess.Popen( - ["powershell", "-ExecutionPolicy", "Bypass", "-File", script], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=False, - ) - else: - logging.error("Unsupported script format.") - return - except Exception as e: - logging.exception(f"Error while running script: {e}") - - def kill_script_processes(self, pids_file): - try: - with open(pids_file) as f: - pids = f.readlines() - for pid in pids: - try: - pid = int(pid.strip()) - if psutil.pid_exists(pid): - process = psutil.Process(pid) - children = process.children(recursive=True) - logging.info(f"Forcibly killing process {pid} and {len(children)} child processes...") - for child in children: - try: - logging.info(f"Forcibly killing child process {child.pid}") - child.kill() - except psutil.NoSuchProcess: - logging.warning(f"Child process {child.pid} already terminated.") - except Exception as e: - logging.exception(f"Error while forcibly killing child process {child.pid}: {e}") - try: - logging.info(f"Forcibly killing main process {pid}") - process.kill() - except psutil.NoSuchProcess: - logging.warning(f"Process {pid} already terminated.") - except Exception as e: - logging.exception(f"Error while forcibly killing main process {pid}: {e}") - else: - logging.warning(f"PID {pid} does not exist.") - except ValueError: - logging.exception(f"Invalid PID value in file: {pid}") - except Exception as e: - logging.exception(f"Error while forcibly killing process {pid}: {e}") - except FileNotFoundError: - logging.exception(f"PID file not found: {pids_file}") - except Exception as e: - logging.exception(f"Error while reading PIDs from file: {e}") - - -class Controller: - def __init__(self, args): - self.scenario_name = args.scenario_name if hasattr(args, "scenario_name") else None - self.start_date_scenario = None - self.federation = args.federation if hasattr(args, "federation") else None - self.topology = args.topology if hasattr(args, "topology") else None - self.controller_port = int(args.controllerport) if hasattr(args, "controllerport") else 5000 - self.waf_port = int(args.wafport) if hasattr(args, "wafport") else 6000 - self.frontend_port = int(args.webport) if hasattr(args, "webport") else 6060 - self.grafana_port = int(args.grafanaport) if hasattr(args, "grafanaport") else 6040 - self.loki_port = int(args.lokiport) if hasattr(args, "lokiport") else 6010 - self.statistics_port = int(args.statsport) if hasattr(args, "statsport") else 8080 - self.simulation = args.simulation - self.config_dir = args.config - self.databases_dir = args.databases if hasattr(args, "databases") else "/opt/nebula" - self.test = args.test if hasattr(args, "test") else False - self.log_dir = args.logs - self.cert_dir = args.certs - self.env_path = args.env - self.production = args.production if hasattr(args, "production") else False - self.advanced_analytics = args.advanced_analytics if hasattr(args, "advanced_analytics") else False - self.matrix = args.matrix if hasattr(args, "matrix") else None - self.root_path = ( - args.root_path - if hasattr(args, "root_path") - else os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ) - self.host_platform = "windows" if sys.platform == "win32" else "unix" - - # Network configuration (nodes deployment in a network) - self.network_subnet = args.network_subnet if hasattr(args, "network_subnet") else None - self.network_gateway = args.network_gateway if hasattr(args, "network_gateway") else None - - # Configure logger - self.configure_logger() - - # Check ports available - if not SocketUtils.is_port_open(self.controller_port): - self.controller_port = SocketUtils.find_free_port() - - if not SocketUtils.is_port_open(self.frontend_port): - self.frontend_port = SocketUtils.find_free_port(self.controller_port + 1) - - if not SocketUtils.is_port_open(self.statistics_port): - self.statistics_port = SocketUtils.find_free_port(self.frontend_port + 1) - - self.config = Config(entity="controller") - self.topologymanager = None - self.n_nodes = 0 - self.mender = None if self.simulation else Mender() - self.use_blockchain = args.use_blockchain if hasattr(args, "use_blockchain") else False - self.gpu_available = False - - # Reference the global app instance - self.app = app - - def configure_logger(self): - log_console_format = "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s" - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(TermEscapeCodeFormatter(log_console_format)) - console_handler_file = logging.FileHandler(os.path.join(self.log_dir, "controller.log"), mode="a") - console_handler_file.setLevel(logging.INFO) - console_handler_file.setFormatter(logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s")) - logging.basicConfig( - level=logging.DEBUG, - handlers=[ - console_handler, - console_handler_file, - ], - ) - uvicorn_loggers = ["uvicorn", "uvicorn.error", "uvicorn.access"] - for logger_name in uvicorn_loggers: - logger = logging.getLogger(logger_name) - logger.handlers = [] # Remove existing handlers - logger.propagate = False # Prevent duplicate logs - handler = logging.FileHandler(os.path.join(self.log_dir, "controller.log"), mode="a") - handler.setFormatter(logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s")) - logger.addHandler(handler) - - def start(self): - banner = """ - β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— - β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— - β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ - β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘ - β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ - β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• - A Platform for Decentralized Federated Learning - Created by Enrique TomΓ‘s MartΓ­nez BeltrΓ‘n - https://github.com/CyberDataLab/nebula - """ - print("\x1b[0;36m" + banner + "\x1b[0m") - - # Load the environment variables - load_dotenv(self.env_path) - - # Save controller pid - with open(os.path.join(os.path.dirname(__file__), "controller.pid"), "w") as f: - f.write(str(os.getpid())) - - # Check information about the environment - check_environment() - - # Save the configuration in environment variables - logging.info("Saving configuration in environment variables...") - os.environ["NEBULA_ROOT"] = self.root_path - os.environ["NEBULA_LOGS_DIR"] = self.log_dir - os.environ["NEBULA_CONFIG_DIR"] = self.config_dir - os.environ["NEBULA_CERTS_DIR"] = self.cert_dir - os.environ["NEBULA_STATISTICS_PORT"] = str(self.statistics_port) - os.environ["NEBULA_ROOT_HOST"] = self.root_path - os.environ["NEBULA_HOST_PLATFORM"] = self.host_platform - - # Start the FastAPI app in a daemon thread - app_thread = threading.Thread(target=self.run_controller_api, daemon=True) - app_thread.start() - logging.info(f"NEBULA Controller is running at port {self.controller_port}") - - if self.production: - self.run_waf() - logging.info(f"NEBULA WAF is running at port {self.waf_port}") - logging.info(f"Grafana Dashboard is running at port {self.grafana_port}") - - if self.test: - self.run_test() - else: - self.run_frontend() - logging.info(f"NEBULA Frontend is running at http://localhost:{self.frontend_port}") - logging.info(f"NEBULA Databases created in {self.databases_dir}") - - # Watchdog for running additional scripts in the host machine (i.e. during the execution of a federation) - event_handler = NebulaEventHandler() - observer = Observer() - observer.schedule(event_handler, path=self.config_dir, recursive=True) - observer.start() - - if self.mender: - logging.info("[Mender.module] Mender module initialized") - time.sleep(2) - mender = Mender() - logging.info("[Mender.module] Getting token from Mender server: {}".format(os.getenv("MENDER_SERVER"))) - mender.renew_token() - time.sleep(2) - logging.info( - "[Mender.module] Getting devices from {} with group Cluster_Thun".format(os.getenv("MENDER_SERVER")) - ) - time.sleep(2) - devices = mender.get_devices_by_group("Cluster_Thun") - logging.info("[Mender.module] Getting a pool of devices: 5 devices") - # devices = devices[:5] - for i in self.config.participants: - logging.info( - "[Mender.module] Device {} | IP: {}".format(i["device_args"]["idx"], i["network_args"]["ip"]) - ) - logging.info("[Mender.module] \tCreating artifacts...") - logging.info("[Mender.module] \tSending NEBULA Core...") - # mender.deploy_artifact_device("my-update-2.0.mender", i['device_args']['idx']) - logging.info("[Mender.module] \tSending configuration...") - time.sleep(5) - sys.exit(0) - - logging.info("Press Ctrl+C for exit from NEBULA (global exit)") - - # Adjust signal handling inside the start method - signal.signal(signal.SIGTERM, self.signal_handler) - signal.signal(signal.SIGINT, self.signal_handler) - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - logging.info("Closing NEBULA (exiting from components)... Please wait") - observer.stop() - self.stop() - - observer.join() - - def signal_handler(self, sig, frame): - # Handle termination signals - logging.info("Received termination signal, shutting down...") - self.stop() - sys.exit(0) - - def run_controller_api(self): - uvicorn.run( - self.app, - host="0.0.0.0", - port=self.controller_port, - log_config=None, # Prevent Uvicorn from configuring logging - ) - - def run_waf(self): - network_name = f"{os.environ['USER']}_nebula-net-base" - base = DockerUtils.create_docker_network(network_name) - - client = docker.from_env() - - volumes_waf = ["/var/log/nginx"] - - ports_waf = [80] - - host_config_waf = client.api.create_host_config( - binds=[f"{os.environ['NEBULA_LOGS_DIR']}/waf/nginx:/var/log/nginx"], - privileged=True, - port_bindings={80: self.waf_port}, - ) - - networking_config_waf = client.api.create_networking_config({ - f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.200") - }) - - container_id_waf = client.api.create_container( - image="nebula-waf", - name=f"{os.environ['USER']}_nebula-waf", - detach=True, - volumes=volumes_waf, - host_config=host_config_waf, - networking_config=networking_config_waf, - ports=ports_waf, - ) - - client.api.start(container_id_waf) - - environment = { - "GF_SECURITY_ADMIN_PASSWORD": "admin", - "GF_USERS_ALLOW_SIGN_UP": "false", - "GF_SERVER_HTTP_PORT": "3000", - "GF_SERVER_PROTOCOL": "http", - "GF_SERVER_DOMAIN": f"localhost:{self.grafana_port}", - "GF_SERVER_ROOT_URL": f"http://localhost:{self.grafana_port}/grafana/", - "GF_SERVER_SERVE_FROM_SUB_PATH": "true", - "GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH": "/var/lib/grafana/dashboards/dashboard.json", - "GF_METRICS_MAX_LIMIT_TSDB": "0", - } - - ports = [3000] - - host_config = client.api.create_host_config( - port_bindings={3000: self.grafana_port}, - ) - - networking_config = client.api.create_networking_config({ - f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.201") - }) - - container_id = client.api.create_container( - image="nebula-waf-grafana", - name=f"{os.environ['USER']}_nebula-waf-grafana", - detach=True, - environment=environment, - host_config=host_config, - networking_config=networking_config, - ports=ports, - ) - - client.api.start(container_id) - - command = ["-config.file=/mnt/config/loki-config.yml"] - - ports_loki = [3100] - - host_config_loki = client.api.create_host_config( - port_bindings={3100: self.loki_port}, - ) - - networking_config_loki = client.api.create_networking_config({ - f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.202") - }) - - container_id_loki = client.api.create_container( - image="nebula-waf-loki", - name=f"{os.environ['USER']}_nebula-waf-loki", - detach=True, - command=command, - host_config=host_config_loki, - networking_config=networking_config_loki, - ports=ports_loki, - ) - - client.api.start(container_id_loki) - - volumes_promtail = ["/var/log/nginx"] - - host_config_promtail = client.api.create_host_config( - binds=[ - f"{os.environ['NEBULA_LOGS_DIR']}/waf/nginx:/var/log/nginx", - ], - ) - - networking_config_promtail = client.api.create_networking_config({ - f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.203") - }) - - container_id_promtail = client.api.create_container( - image="nebula-waf-promtail", - name=f"{os.environ['USER']}_nebula-waf-promtail", - detach=True, - volumes=volumes_promtail, - host_config=host_config_promtail, - networking_config=networking_config_promtail, - ) - - client.api.start(container_id_promtail) - - def run_frontend(self): - if sys.platform == "win32": - if not os.path.exists("//./pipe/docker_Engine"): - raise Exception( - "Docker is not running, please check if Docker is running and Docker Compose is installed." - ) - else: - if not os.path.exists("/var/run/docker.sock"): - raise Exception( - "/var/run/docker.sock not found, please check if Docker is running and Docker Compose is installed." - ) - - try: - subprocess.check_call(["nvidia-smi"]) - self.gpu_available = True - except Exception: - logging.info("No GPU available for the frontend, nodes will be deploy in CPU mode") - - network_name = f"{os.environ['USER']}_nebula-net-base" - - # Create the Docker network - base = DockerUtils.create_docker_network(network_name) - - client = docker.from_env() - - environment = { - "NEBULA_CONTROLLER_NAME": os.environ["USER"], - "NEBULA_PRODUCTION": self.production, - "NEBULA_GPU_AVAILABLE": self.gpu_available, - "NEBULA_ADVANCED_ANALYTICS": self.advanced_analytics, - "NEBULA_FRONTEND_LOG": "/nebula/app/logs/frontend.log", - "NEBULA_LOGS_DIR": "/nebula/app/logs/", - "NEBULA_CONFIG_DIR": "/nebula/app/config/", - "NEBULA_CERTS_DIR": "/nebula/app/certs/", - "NEBULA_ENV_PATH": "/nebula/app/.env", - "NEBULA_ROOT_HOST": self.root_path, - "NEBULA_HOST_PLATFORM": self.host_platform, - "NEBULA_DEFAULT_USER": "admin", - "NEBULA_DEFAULT_PASSWORD": "admin", - "NEBULA_FRONTEND_PORT": self.frontend_port, - "NEBULA_CONTROLLER_PORT": self.controller_port, - "NEBULA_CONTROLLER_HOST": "host.docker.internal", - } - - volumes = ["/nebula", "/var/run/docker.sock", "/etc/nginx/sites-available/default"] - - ports = [80, 8080] - - host_config = client.api.create_host_config( - binds=[ - f"{self.root_path}:/nebula", - "/var/run/docker.sock:/var/run/docker.sock", - f"{self.root_path}/nebula/frontend/config/nebula:/etc/nginx/sites-available/default", - f"{self.databases_dir}:/nebula/nebula/frontend/databases", - ], - extra_hosts={"host.docker.internal": "host-gateway"}, - port_bindings={80: self.frontend_port, 8080: self.statistics_port}, - ) - - networking_config = client.api.create_networking_config({ - f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.100") - }) - - container_id = client.api.create_container( - image="nebula-frontend", - name=f"{os.environ['USER']}_nebula-frontend", - detach=True, - environment=environment, - volumes=volumes, - host_config=host_config, - networking_config=networking_config, - ports=ports, - ) - - client.api.start(container_id) - - def run_test(self): - deploy_tests.start() - - @staticmethod - def stop_waf(): - DockerUtils.remove_containers_by_prefix(f"{os.environ['USER']}_nebula-waf") - - @staticmethod - def stop(): - logging.info("Closing NEBULA (exiting from components)... Please wait") - DockerUtils.remove_containers_by_prefix(f"{os.environ['USER']}_") - ScenarioManagement.stop_blockchain() - ScenarioManagement.stop_participants() - Controller.stop_waf() - DockerUtils.remove_docker_networks_by_prefix(f"{os.environ['USER']}_") - controller_pid_file = os.path.join(os.path.dirname(__file__), "controller.pid") - try: - with open(controller_pid_file) as f: - pid = int(f.read()) - os.kill(pid, signal.SIGKILL) - os.remove(controller_pid_file) - except Exception as e: - logging.exception(f"Error while killing controller process: {e}") - sys.exit(0) diff --git a/nebula/controller/controller.py b/nebula/controller/controller.py new file mode 100755 index 000000000..cc0bc7826 --- /dev/null +++ b/nebula/controller/controller.py @@ -0,0 +1,1600 @@ +import asyncio +import datetime +import importlib +import json +import logging +import os +import re +import signal +import subprocess +import sys +import threading +import time +from typing import Annotated + +import aiohttp +import docker +import psutil +import uvicorn +from dotenv import load_dotenv +from fastapi import Body, FastAPI, Request, status, HTTPException, Path +from watchdog.events import PatternMatchingEventHandler +from watchdog.observers import Observer + +from nebula.addons.env import check_environment +from nebula.config.config import Config +from nebula.config.mender import Mender +from nebula.controller.scenarios import Scenario, ScenarioManagement +from nebula.utils import DockerUtils, SocketUtils + + +# Setup controller logger +class TermEscapeCodeFormatter(logging.Formatter): + """ + Custom logging formatter that removes ANSI terminal escape codes from log messages. + + This formatter is useful when you want to clean up log outputs by stripping out + any terminal color codes or formatting sequences before logging them to a file + or other non-terminal output. + + Attributes: + fmt (str): Format string for the log message. + datefmt (str): Format string for the date in the log message. + style (str): Formatting style (default is '%'). + validate (bool): Whether to validate the format string. + + Methods: + format(record): Strips ANSI escape codes from the log message and formats it. + """ + + def __init__(self, fmt=None, datefmt=None, style="%", validate=True): + """ + Initializes the TermEscapeCodeFormatter. + + Args: + fmt (str, optional): The format string for the log message. + datefmt (str, optional): The format string for the date. + style (str, optional): The formatting style. Defaults to '%'. + validate (bool, optional): Whether to validate the format string. Defaults to True. + """ + super().__init__(fmt, datefmt, style, validate) + + def format(self, record): + """ + Formats the specified log record, stripping out any ANSI escape codes. + + Args: + record (logging.LogRecord): The log record to be formatted. + + Returns: + str: The formatted log message with escape codes removed. + """ + escape_re = re.compile(r"\x1b\[[0-9;]*m") + record.msg = re.sub(escape_re, "", str(record.msg)) + return super().format(record) + +os.environ["NEBULA_CONTROLLER_NAME"] = os.environ["USER"] + +# Initialize FastAPI app outside the Controller class +app = FastAPI() + +# Define endpoints outside the Controller class +@app.get("/") +async def read_root(): + """ + Root endpoint of the NEBULA Controller API. + + Returns: + dict: A welcome message indicating the API is accessible. + """ + return {"message": "Welcome to the NEBULA Controller API"} + + +@app.get("/status") +async def get_status(): + """ + Check the status of the NEBULA Controller API. + + Returns: + dict: A status message confirming the API is running. + """ + return {"status": "NEBULA Controller API is running"} + + +@app.get("/resources") +async def get_resources(): + """ + Get system resource usage including RAM and GPU memory usage. + + Returns: + dict: A dictionary containing: + - gpus (int): Number of GPUs detected. + - memory_percent (float): Percentage of used RAM. + - gpu_memory_percent (List[float]): List of GPU memory usage percentages. + """ + devices = 0 + gpu_memory_percent = [] + + # Obtain available RAM + memory_info = await asyncio.to_thread(psutil.virtual_memory) + + if importlib.util.find_spec("pynvml") is not None: + try: + import pynvml + + await asyncio.to_thread(pynvml.nvmlInit) + devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) + + # Obtain GPU info + for i in range(devices): + handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) + memory_info_gpu = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) + memory_used_percent = (memory_info_gpu.used / memory_info_gpu.total) * 100 + gpu_memory_percent.append(memory_used_percent) + + except Exception: # noqa: S110 + pass + + return { + # "cpu_percent": psutil.cpu_percent(), + "gpus": devices, + "memory_percent": memory_info.percent, + "gpu_memory_percent": gpu_memory_percent, + } + + +@app.get("/least_memory_gpu") +async def get_least_memory_gpu(): + """ + Identify the GPU with the highest memory usage above a threshold (50%). + + Note: + Despite the name, this function returns the GPU using the **most** + memory above 50% usage. + + Returns: + dict: A dictionary with the index of the GPU using the most memory above the threshold, + or None if no such GPU is found. + """ + gpu_with_least_memory_index = None + + if importlib.util.find_spec("pynvml") is not None: + max_memory_used_percent = 50 + try: + import pynvml + + await asyncio.to_thread(pynvml.nvmlInit) + devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) + + # Obtain GPU info + for i in range(devices): + handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) + memory_info = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) + memory_used_percent = (memory_info.used / memory_info.total) * 100 + + # Obtain GPU with less memory available + if memory_used_percent > max_memory_used_percent: + max_memory_used_percent = memory_used_percent + gpu_with_least_memory_index = i + + except Exception: # noqa: S110 + pass + + return { + "gpu_with_least_memory_index": gpu_with_least_memory_index, + } + + +@app.get("/available_gpus/") +async def get_available_gpu(): + """ + Get the list of GPUs with memory usage below 5%. + + Returns: + dict: A dictionary with a list of GPU indices that are mostly free (usage < 5%). + """ + available_gpus = [] + + if importlib.util.find_spec("pynvml") is not None: + try: + import pynvml + + await asyncio.to_thread(pynvml.nvmlInit) + devices = await asyncio.to_thread(pynvml.nvmlDeviceGetCount) + + # Obtain GPU info + for i in range(devices): + handle = await asyncio.to_thread(pynvml.nvmlDeviceGetHandleByIndex, i) + memory_info = await asyncio.to_thread(pynvml.nvmlDeviceGetMemoryInfo, handle) + memory_used_percent = (memory_info.used / memory_info.total) * 100 + + # Obtain available GPUs + if memory_used_percent < 5: + available_gpus.append(i) + + return { + "available_gpus": available_gpus, + } + except Exception: # noqa: S110 + pass + + +@app.post("/scenarios/run") +async def run_scenario( + scenario_data: dict = Body(..., embed=True), + role: str = Body(..., embed=True), + user: str = Body(..., embed=True) +): + """ + Launches a new scenario based on the provided configuration. + + Args: + scenario_data (dict): The complete configuration of the scenario to be executed. + role (str): The role of the user initiating the scenario. + user (str): The username of the user initiating the scenario. + + Returns: + str: The name of the scenario that was started. + """ + + import subprocess + + from nebula.controller.scenarios import ScenarioManagement + + # Manager for the actual scenario + scenarioManagement = ScenarioManagement(scenario_data, user) + + await update_scenario( + scenario_name=scenarioManagement.scenario_name, + start_time=scenarioManagement.start_date_scenario, + end_time="", + scenario=scenario_data, + status="running", + role=role, + username=user + ) + + # Run the actual scenario + try: + if scenarioManagement.scenario.mobility: + additional_participants = scenario_data["additional_participants"] + schema_additional_participants = scenario_data["schema_additional_participants"] + scenarioManagement.load_configurations_and_start_nodes( + additional_participants, schema_additional_participants + ) + else: + scenarioManagement.load_configurations_and_start_nodes() + except subprocess.CalledProcessError as e: + logging.exception(f"Error docker-compose up: {e}") + return + + return scenarioManagement.scenario_name + + +@app.post("/scenarios/remove") +async def remove_scenario( + scenario_name: str = Body(..., embed=True) +): + """ + Removes a scenario from the database by its name. + + Args: + scenario_name (str): Name of the scenario to remove. + + Returns: + dict: A message indicating successful removal. + """ + from nebula.controller.database import remove_scenario_by_name + + try: + remove_scenario_by_name(scenario_name) + except Exception as e: + logging.error(f"Error removing scenario {scenario_name}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Scenario {scenario_name} removed successfully"} + + +@app.get("/scenarios/{user}/{role}") +async def get_scenarios( + user: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid username" + ) + ], + role: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid role" + ) + ] +): + """ + Retrieves all scenarios associated with a given user and role. + + Args: + user (str): Username to filter scenarios. + role (str): Role of the user (e.g., "admin"). + + Returns: + dict: A list of scenarios and the currently running scenario. + """ + from nebula.controller.database import get_all_scenarios_and_check_completed, get_running_scenario + + try: + scenarios = get_all_scenarios_and_check_completed(username=user, role=role) + if role == "admin": + scenario_running = get_running_scenario() + else: + scenario_running = get_running_scenario(username=user) + except Exception as e: + logging.error(f"Error obtaining scenarios: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"scenarios": scenarios, "scenario_running": scenario_running} + + +@app.post("/scenarios/update") +async def update_scenario( + scenario_name: str = Body(..., embed=True), + start_time: str = Body(..., embed=True), + end_time: str = Body(..., embed=True), + scenario: dict = Body(..., embed=True), + status: str = Body(..., embed=True), + role: str = Body(..., embed=True), + username: str = Body(..., embed=True) +): + """ + Updates the status and metadata of a scenario. + + Args: + scenario_name (str): Name of the scenario. + start_time (str): Start time of the scenario. + end_time (str): End time of the scenario. + scenario (dict): Scenario configuration. + status (str): New status of the scenario (e.g., "running", "finished"). + role (str): Role associated with the scenario. + username (str): User performing the update. + + Returns: + dict: A message confirming the update. + """ + from nebula.controller.database import scenario_update_record + + try: + scenario = Scenario.from_dict(scenario) + scenario_update_record(scenario_name, start_time, end_time, scenario, status, role, username) + except Exception as e: + logging.error(f"Error updating scenario {scenario_name}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Scenario {scenario_name} updated successfully"} + + +@app.post("/scenarios/set_status_to_finished") +async def set_scenario_status_to_finished( + scenario_name: str = Body(..., embed=True), + all: bool = Body(False, embed=True) +): + """ + Sets the status of a scenario (or all scenarios) to 'finished'. + + Args: + scenario_name (str): Name of the scenario to mark as finished. + all (bool): If True, sets all scenarios to finished. + + Returns: + dict: A message confirming the operation. + """ + from nebula.controller.database import scenario_set_status_to_finished, scenario_set_all_status_to_finished + + try: + if all: + scenario_set_all_status_to_finished() + else: + scenario_set_status_to_finished(scenario_name) + except Exception as e: + logging.error(f"Error setting scenario {scenario_name} to finished: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Scenario {scenario_name} status set to finished successfully"} + + +@app.get("/scenarios/running") +async def get_running_scenario(get_all: bool = False): + """ + Retrieves the currently running scenario(s). + + Args: + get_all (bool): If True, retrieves all running scenarios. + + Returns: + dict or list: Running scenario(s) information. + """ + from nebula.controller.database import get_running_scenario + + try: + return get_running_scenario(get_all=get_all) + except Exception as e: + logging.error(f"Error obtaining running scenario: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/scenarios/check") +async def check_scenario(role: str, scenario_name: str): + """ + Checks if a scenario is allowed for a specific role. + + Args: + role (str): Role to validate. + scenario_name (str): Name of the scenario. + + Returns: + dict: Whether the scenario is allowed for the role. + """ + from nebula.controller.database import check_scenario_with_role + + try: + allowed = check_scenario_with_role(role, scenario_name) + return {"allowed": allowed} + except Exception as e: + logging.error(f"Error checking scenario with role: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/scenarios/{scenario_name}") +async def get_scenario_by_name( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ) + ] +): + """ + Fetches a scenario by its name. + + Args: + scenario_name (str): The name of the scenario. + + Returns: + dict: The scenario data. + """ + from nebula.controller.database import get_scenario_by_name + + try: + scenario = get_scenario_by_name(scenario_name) + except Exception as e: + logging.error(f"Error obtaining scenario {scenario_name}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return scenario + + +@app.get("/nodes/{scenario_name}") +async def list_nodes_by_scenario_name( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ) + ] +): + """ + Lists all nodes associated with a specific scenario. + + Args: + scenario_name (str): Name of the scenario. + + Returns: + list: List of nodes. + """ + from nebula.controller.database import list_nodes_by_scenario_name + + try: + nodes = list_nodes_by_scenario_name(scenario_name) + except Exception as e: + logging.error(f"Error obtaining nodes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return nodes + + +@app.post("/nodes/{scenario_name}/update") +async def update_nodes( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ), + ], + request: Request +): + """ + Updates the configuration of a node in the database and notifies the frontend. + + Args: + scenario_name (str): The scenario to which the node belongs. + request (Request): The HTTP request containing the node data. + + Returns: + dict: Confirmation or response from the frontend. + """ + from nebula.controller.database import update_node_record + try: + config = await request.json() + timestamp = datetime.datetime.now() + # Update the node in database + await update_node_record( + str(config["device_args"]["uid"]), + str(config["device_args"]["idx"]), + str(config["network_args"]["ip"]), + str(config["network_args"]["port"]), + str(config["device_args"]["role"]), + str(config["network_args"]["neighbors"]), + str(config["mobility_args"]["latitude"]), + str(config["mobility_args"]["longitude"]), + str(timestamp), + str(config["scenario_args"]["federation"]), + str(config["federation_args"]["round"]), + str(config["scenario_args"]["name"]), + str(config["tracking_args"]["run_hash"]), + str(config["device_args"]["malicious"]), + ) + except Exception as e: + logging.error(f"Error updating nodes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + port = os.environ["NEBULA_FRONTEND_PORT"] + url = f"http://localhost:{port}/platform/dashboard/{scenario_name}/node/update" + + config["timestamp"] = str(timestamp) + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=config) as response: + if response.status == 200: + return await response.json() + else: + raise HTTPException(status_code=response.status, detail="Error posting data") + + return {"message": "Nodes updated successfully in the database"} + + +@app.post("/nodes/{scenario_name}/done") +async def node_done( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ), + ], + request: Request +): + """ + Endpoint to forward node status to the frontend. + + Receives a JSON payload and forwards it to the frontend's /node/done route + for the given scenario. + + Parameters: + - scenario_name: Name of the scenario. + - request: HTTP request with JSON body. + + Returns the response from the frontend or raises an HTTPException if it fails. + """ + port = os.environ["NEBULA_FRONTEND_PORT"] + url = f"http://localhost:{port}/platform/dashboard/{scenario_name}/node/done" + + data = await request.json() + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data) as response: + if response.status == 200: + return await response.json() + else: + raise HTTPException(status_code=response.status, detail="Error posting data") + + return {"message": "Nodes done"} + + +@app.post("/nodes/remove") +async def remove_nodes_by_scenario_name( + scenario_name: str = Body(..., embed=True) +): + """ + Endpoint to remove all nodes associated with a scenario. + + Body Parameters: + - scenario_name: Name of the scenario whose nodes should be removed. + + Returns a success message or an error if something goes wrong. + """ + from nebula.controller.database import remove_nodes_by_scenario_name + + try: + remove_nodes_by_scenario_name(scenario_name) + except Exception as e: + logging.error(f"Error removing nodes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Nodes for scenario {scenario_name} removed successfully"} + + +@app.get("/notes/{scenario_name}") +async def get_notes_by_scenario_name( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ) + ] +): + """ + Endpoint to retrieve notes associated with a scenario. + + Path Parameters: + - scenario_name: Name of the scenario. + + Returns the notes or raises an HTTPException on error. + """ + from nebula.controller.database import get_notes + + try: + notes = get_notes(scenario_name) + except Exception as e: + logging.error(f"Error obtaining notes {notes}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return notes + + +@app.post("/notes/update") +async def update_notes_by_scenario_name( + scenario_name: str = Body(..., embed=True), + notes: str = Body(..., embed=True) +): + """ + Endpoint to update notes for a given scenario. + + Body Parameters: + - scenario_name: Name of the scenario. + - notes: Text content to store as notes. + + Returns a success message or an error if something goes wrong. + """ + from nebula.controller.database import save_notes + + try: + save_notes(scenario_name, notes) + except Exception as e: + logging.error(f"Error updating notes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Notes for scenario {scenario_name} updated successfully"} + + +@app.post("/notes/remove") +async def remove_notes_by_scenario_name( + scenario_name: str = Body(..., embed=True) +): + """ + Endpoint to remove notes associated with a scenario. + + Body Parameters: + - scenario_name: Name of the scenario. + + Returns a success message or an error if something goes wrong. + """ + from nebula.controller.database import remove_note + + try: + remove_note(scenario_name) + except Exception as e: + logging.error(f"Error removing notes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return {"message": f"Notes for scenario {scenario_name} removed successfully"} + + +@app.get("/user/list") +async def list_users_controller(all_info: bool = False): + """ + Endpoint to list all users in the database. + + Query Parameters: + - all_info (bool): If True, returns full user info as dictionaries. + + Returns a list of users or raises an HTTPException on error. + """ + from nebula.controller.database import list_users + + try: + user_list = list_users(all_info) + if all_info: + # Convert each sqlite3.Row to a dictionary so that it is JSON serializable. + user_list = [dict(user) for user in user_list] + return {"users": user_list} + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving users: {e}" + ) + + +@app.get("/user/{scenario_name}") +async def get_user_by_scenario_name( + scenario_name: Annotated[ + str, + Path( + regex="^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + description="Valid scenario name" + ) + ] +): + """ + Endpoint to retrieve the user assigned to a scenario. + + Path Parameters: + - scenario_name: Name of the scenario. + + Returns user info or raises an HTTPException on error. + """ + from nebula.controller.database import get_user_by_scenario_name + + try: + user = get_user_by_scenario_name(scenario_name) + except Exception as e: + logging.error(f"Error obtaining user {user}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return user + + +@app.post("/user/add") +async def add_user_controller( + user: str = Body(...), + password: str = Body(...), + role: str = Body(...) +): + """ + Endpoint to add a new user to the database. + + Body Parameters: + - user: Username. + - password: Password for the new user. + - role: Role assigned to the user (e.g., "admin", "user"). + + Returns a success message or an error if the user could not be added. + """ + from nebula.controller.database import add_user + + try: + add_user(user, password, role) + return {"detail": "User added successfully"} + except Exception as e: + logging.error(f"Error adding user: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error adding user: {e}" + ) + + +@app.post("/user/delete") +async def remove_user_controller( + user: str = Body(..., embed=True) +): + """ + Controller endpoint that inserts a new user into the database. + + Parameters: + - user: The username for the new user. + + Returns a success message if the user is deleted, or an HTTP error if an exception occurs. + """ + from nebula.controller.database import delete_user_from_db + + try: + delete_user_from_db(user) + return {"detail": "User deleted successfully"} + except Exception as e: + logging.error(f"Error deleting user: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error deleting user: {e}" + ) + + +@app.post("/user/update") +async def add_user_controller( + user: str = Body(...), + password: str = Body(...), + role: str = Body(...) +): + """ + Controller endpoint that modifies a user of the database. + + Parameters: + - user: The username of the user. + - password: The user's password. + - role: The role of the user. + + Returns a success message if the user is updated, or an HTTP error if an exception occurs. + """ + from nebula.controller.database import update_user + + try: + update_user(user, password, role) + return {"detail": "User updated successfully"} + except Exception as e: + logging.error(f"Error updating user: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error updating user: {e}" + ) + + +@app.post("/user/verify") +async def add_user_controller( + user: str = Body(...), + password: str = Body(...) +): + """ + Endpoint to verify user credentials. + + Body Parameters: + - user: Username. + - password: Password. + + Returns the user role on success or raises an error on failure. + """ + from nebula.controller.database import list_users, verify, get_user_info + + try: + user_submitted = user.upper() + if (user_submitted in list_users()) and verify(user_submitted, password): + user_info = get_user_info(user_submitted) + return {"user": user_submitted, "role": user_info[2]} + else: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + except Exception as e: + logging.error(f"Error verifying user: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error verifying user: {e}" + ) + + +class NebulaEventHandler(PatternMatchingEventHandler): + """ + NebulaEventHandler handles file system events for .sh scripts. + + This class monitors the creation, modification, and deletion of .sh scripts + in a specified directory. + """ + + patterns = ["*.sh", "*.ps1"] + + def __init__(self): + super(NebulaEventHandler, self).__init__() + self.last_processed = {} + self.timeout_ns = 5 * 1e9 + self.processing_files = set() + self.lock = threading.Lock() + + def _should_process_event(self, src_path: str) -> bool: + current_time_ns = time.time_ns() + logging.info(f"Current time (ns): {current_time_ns}") + with self.lock: + if src_path in self.last_processed: + logging.info(f"Last processed time for {src_path}: {self.last_processed[src_path]}") + last_time = self.last_processed[src_path] + if current_time_ns - last_time < self.timeout_ns: + return False + self.last_processed[src_path] = current_time_ns + return True + + def _is_being_processed(self, src_path: str) -> bool: + with self.lock: + if src_path in self.processing_files: + logging.info(f"Skipping {src_path} as it is already being processed.") + return True + self.processing_files.add(src_path) + return False + + def _processing_done(self, src_path: str): + with self.lock: + if src_path in self.processing_files: + self.processing_files.remove(src_path) + + def verify_nodes_ports(self, src_path): + parent_dir = os.path.dirname(src_path) + base_dir = os.path.basename(parent_dir) + scenario_path = os.path.join(os.path.dirname(parent_dir), base_dir) + + try: + port_mapping = {} + new_port_start = 50000 + + participant_files = sorted( + f for f in os.listdir(scenario_path) if f.endswith(".json") and f.startswith("participant") + ) + + for filename in participant_files: + file_path = os.path.join(scenario_path, filename) + with open(file_path) as json_file: + node = json.load(json_file) + current_port = node["network_args"]["port"] + port_mapping[current_port] = SocketUtils.find_free_port(start_port=new_port_start) + logging.info( + f"Participant file: {filename} | Current port: {current_port} | New port: {port_mapping[current_port]}" + ) + new_port_start = port_mapping[current_port] + 1 + + for filename in participant_files: + file_path = os.path.join(scenario_path, filename) + with open(file_path) as json_file: + node = json.load(json_file) + current_port = node["network_args"]["port"] + node["network_args"]["port"] = port_mapping[current_port] + neighbors = node["network_args"]["neighbors"] + + for old_port, new_port in port_mapping.items(): + neighbors = neighbors.replace(f":{old_port}", f":{new_port}") + + node["network_args"]["neighbors"] = neighbors + + with open(file_path, "w") as f: + json.dump(node, f, indent=4) + + except Exception as e: + print(f"Error processing JSON files: {e}") + + def on_created(self, event): + """ + Handles the event when a file is created. + """ + if event.is_directory: + return + src_path = event.src_path + if not self._should_process_event(src_path): + return + if self._is_being_processed(src_path): + return + logging.info("File created: %s" % src_path) + try: + self.verify_nodes_ports(src_path) + self.run_script(src_path) + finally: + self._processing_done(src_path) + + def on_deleted(self, event): + """ + Handles the event when a file is deleted. + """ + if event.is_directory: + return + src_path = event.src_path + if not self._should_process_event(src_path): + return + if self._is_being_processed(src_path): + return + logging.info("File deleted: %s" % src_path) + directory_script = os.path.dirname(src_path) + pids_file = os.path.join(directory_script, "current_scenario_pids.txt") + logging.info(f"Killing processes from {pids_file}") + try: + self.kill_script_processes(pids_file) + os.remove(pids_file) + except FileNotFoundError: + logging.warning(f"{pids_file} not found.") + except Exception as e: + logging.exception(f"Error while killing processes: {e}") + finally: + self._processing_done(src_path) + + def run_script(self, script): + try: + logging.info(f"Running script: {script}") + if script.endswith(".sh"): + result = subprocess.run(["bash", script], capture_output=True, text=True) + logging.info(f"Script output:\n{result.stdout}") + if result.stderr: + logging.error(f"Script error:\n{result.stderr}") + elif script.endswith(".ps1"): + subprocess.Popen( + ["powershell", "-ExecutionPolicy", "Bypass", "-File", script], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + else: + logging.error("Unsupported script format.") + return + except Exception as e: + logging.exception(f"Error while running script: {e}") + + def kill_script_processes(self, pids_file): + try: + with open(pids_file) as f: + pids = f.readlines() + for pid in pids: + try: + pid = int(pid.strip()) + if psutil.pid_exists(pid): + process = psutil.Process(pid) + children = process.children(recursive=True) + logging.info(f"Forcibly killing process {pid} and {len(children)} child processes...") + for child in children: + try: + logging.info(f"Forcibly killing child process {child.pid}") + child.kill() + except psutil.NoSuchProcess: + logging.warning(f"Child process {child.pid} already terminated.") + except Exception as e: + logging.exception(f"Error while forcibly killing child process {child.pid}: {e}") + try: + logging.info(f"Forcibly killing main process {pid}") + process.kill() + except psutil.NoSuchProcess: + logging.warning(f"Process {pid} already terminated.") + except Exception as e: + logging.exception(f"Error while forcibly killing main process {pid}: {e}") + else: + logging.warning(f"PID {pid} does not exist.") + except ValueError: + logging.exception(f"Invalid PID value in file: {pid}") + except Exception as e: + logging.exception(f"Error while forcibly killing process {pid}: {e}") + except FileNotFoundError: + logging.exception(f"PID file not found: {pids_file}") + except Exception as e: + logging.exception(f"Error while reading PIDs from file: {e}") + + +class Controller: + def __init__(self, args): + """ + Initializes the main controller class for the NEBULA system. + + Parses and stores all configuration values from the provided `args` object, + which is expected to come from an argument parser (e.g., argparse). + + Parameters (from `args`): + - scenario_name (str): Name of the current scenario. + - federation (str): Federation type used in the simulation. + - topology (str): Path to the topology file. + - controllerport (int): Port for the controller service (default: 5000). + - wafport (int): Port for the WAF service (default: 6000). + - webport (int): Port for the frontend (default: 6060). + - grafanaport (int): Port for Grafana (default: 6040). + - lokiport (int): Port for Loki logs (default: 6010). + - statsport (int): Port for the statistics module (default: 8080). + - simulation (bool): Whether the scenario runs in simulation mode. + - config (str): Path to the configuration directory. + - databases (str): Path to the databases directory (default: /opt/nebula). + - logs (str): Path to the log directory. + - certs (str): Path to the certificates directory. + - env (str): Path to the environment (venv, etc.). + - production (bool): Whether the system is running in production mode. + - advanced_analytics (bool): Whether advanced analytics are enabled. + - matrix (str): Path to the evaluation matrix file. + - root_path (str): Root path of the application. + - network_subnet (str): Custom Docker network subnet. + - network_gateway (str): Custom Docker network gateway. + - use_blockchain (bool): Whether the blockchain component is enabled. + + This method also: + - Sets platform type (`windows` or `unix`) + - Configures logging + - Dynamically selects free ports if the specified ones are in use + - Initializes configuration and deployment objects + """ + self.scenario_name = args.scenario_name if hasattr(args, "scenario_name") else None + self.start_date_scenario = None + self.federation = args.federation if hasattr(args, "federation") else None + self.topology = args.topology if hasattr(args, "topology") else None + self.controller_port = int(args.controllerport) if hasattr(args, "controllerport") else 5000 + self.waf_port = int(args.wafport) if hasattr(args, "wafport") else 6000 + self.frontend_port = int(args.webport) if hasattr(args, "webport") else 6060 + self.grafana_port = int(args.grafanaport) if hasattr(args, "grafanaport") else 6040 + self.loki_port = int(args.lokiport) if hasattr(args, "lokiport") else 6010 + self.statistics_port = int(args.statsport) if hasattr(args, "statsport") else 8080 + self.simulation = args.simulation + self.config_dir = args.config + self.databases_dir = args.databases if hasattr(args, "databases") else "/opt/nebula" + self.log_dir = args.logs + self.cert_dir = args.certs + self.env_path = args.env + self.production = args.production if hasattr(args, "production") else False + self.advanced_analytics = args.advanced_analytics if hasattr(args, "advanced_analytics") else False + self.matrix = args.matrix if hasattr(args, "matrix") else None + self.root_path = ( + args.root_path + if hasattr(args, "root_path") + else os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + ) + self.host_platform = "windows" if sys.platform == "win32" else "unix" + + # Network configuration (nodes deployment in a network) + self.network_subnet = args.network_subnet if hasattr(args, "network_subnet") else None + self.network_gateway = args.network_gateway if hasattr(args, "network_gateway") else None + + # Configure logger + self.configure_logger() + + # Check ports available + if not SocketUtils.is_port_open(self.controller_port): + self.controller_port = SocketUtils.find_free_port() + + if not SocketUtils.is_port_open(self.frontend_port): + self.frontend_port = SocketUtils.find_free_port(self.controller_port + 1) + + if not SocketUtils.is_port_open(self.statistics_port): + self.statistics_port = SocketUtils.find_free_port(self.frontend_port + 1) + + self.config = Config(entity="controller") + self.topologymanager = None + self.n_nodes = 0 + self.mender = None if self.simulation else Mender() + self.use_blockchain = args.use_blockchain if hasattr(args, "use_blockchain") else False + self.gpu_available = False + + # Reference the global app instance + self.app = app + + def configure_logger(self): + """ + Configures the logging system for the controller. + + - Sets a format for console and file logging. + - Creates a console handler with INFO level. + - Creates a file handler for 'controller.log' with INFO level. + - Configures specific Uvicorn loggers to use the file handler + without duplicating log messages. + """ + log_console_format = "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s" + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(TermEscapeCodeFormatter(log_console_format)) + console_handler_file = logging.FileHandler(os.path.join(self.log_dir, "controller.log"), mode="a") + console_handler_file.setLevel(logging.INFO) + console_handler_file.setFormatter(logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s")) + logging.basicConfig( + level=logging.DEBUG, + handlers=[ + console_handler, + console_handler_file, + ], + ) + uvicorn_loggers = ["uvicorn", "uvicorn.error", "uvicorn.access"] + for logger_name in uvicorn_loggers: + logger = logging.getLogger(logger_name) + logger.handlers = [] # Remove existing handlers + logger.propagate = False # Prevent duplicate logs + handler = logging.FileHandler(os.path.join(self.log_dir, "controller.log"), mode="a") + handler.setFormatter(logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s")) + logger.addHandler(handler) + + def start(self): + """ + Starts the NEBULA controller. + + - Displays the welcome banner. + - Loads environment variables from the `.env` file. + - Saves the process PID to 'controller.pid'. + - Checks the environment and saves configuration to environment variables. + - Launches the FastAPI app in a daemon thread. + - Initializes databases. + - In production mode, starts the WAF and logs WAF and Grafana ports. + - Runs the frontend and logs its URL. + - Starts a watchdog to monitor configuration directory changes. + - If enabled, initializes the Mender module for artifact deployment. + - Captures SIGTERM and SIGINT signals for graceful shutdown. + - Keeps the process running until termination signal or Ctrl+C. + """ + banner = """ + β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— + β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— + β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ + β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘ + β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ + β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• + A Platform for Decentralized Federated Learning + Created by Enrique TomΓ‘s MartΓ­nez BeltrΓ‘n + https://github.com/CyberDataLab/nebula + """ + print("\x1b[0;36m" + banner + "\x1b[0m") + + # Load the environment variables + load_dotenv(self.env_path) + + # Save controller pid + with open(os.path.join(os.path.dirname(__file__), "controller.pid"), "w") as f: + f.write(str(os.getpid())) + + # Check information about the environment + check_environment() + + # Save the configuration in environment variables + logging.info("Saving configuration in environment variables...") + os.environ["NEBULA_ROOT"] = self.root_path + os.environ["NEBULA_LOGS_DIR"] = self.log_dir + os.environ["NEBULA_CONFIG_DIR"] = self.config_dir + os.environ["NEBULA_CERTS_DIR"] = self.cert_dir + os.environ["NEBULA_ROOT_HOST"] = self.root_path + os.environ["NEBULA_HOST_PLATFORM"] = self.host_platform + os.environ["NEBULA_CONTROLLER_HOST"] = "host.docker.internal" + os.environ["NEBULA_STATISTICS_PORT"] = str(self.statistics_port) + os.environ["NEBULA_CONTROLLER_PORT"] = str(self.controller_port) + os.environ["NEBULA_FRONTEND_PORT"] = str(self.frontend_port) + + # Start the FastAPI app in a daemon thread + app_thread = threading.Thread(target=self.run_controller_api, daemon=True) + app_thread.start() + logging.info(f"NEBULA Controller is running at port {self.controller_port}") + + from nebula.controller.database import initialize_databases + + asyncio.run(initialize_databases(self.databases_dir)) + + if self.production: + self.run_waf() + logging.info(f"NEBULA WAF is running at port {self.waf_port}") + logging.info(f"Grafana Dashboard is running at port {self.grafana_port}") + + self.run_frontend() + logging.info(f"NEBULA Frontend is running at http://localhost:{self.frontend_port}") + logging.info(f"NEBULA Databases created in {self.databases_dir}") + + # Watchdog for running additional scripts in the host machine (i.e. during the execution of a federation) + event_handler = NebulaEventHandler() + observer = Observer() + observer.schedule(event_handler, path=self.config_dir, recursive=True) + observer.start() + + if self.mender: + logging.info("[Mender.module] Mender module initialized") + time.sleep(2) + mender = Mender() + logging.info("[Mender.module] Getting token from Mender server: {}".format(os.getenv("MENDER_SERVER"))) + mender.renew_token() + time.sleep(2) + logging.info( + "[Mender.module] Getting devices from {} with group Cluster_Thun".format(os.getenv("MENDER_SERVER")) + ) + time.sleep(2) + devices = mender.get_devices_by_group("Cluster_Thun") + logging.info("[Mender.module] Getting a pool of devices: 5 devices") + # devices = devices[:5] + for i in self.config.participants: + logging.info( + "[Mender.module] Device {} | IP: {}".format(i["device_args"]["idx"], i["network_args"]["ip"]) + ) + logging.info("[Mender.module] \tCreating artifacts...") + logging.info("[Mender.module] \tSending NEBULA Core...") + # mender.deploy_artifact_device("my-update-2.0.mender", i['device_args']['idx']) + logging.info("[Mender.module] \tSending configuration...") + time.sleep(5) + sys.exit(0) + + logging.info("Press Ctrl+C for exit from NEBULA (global exit)") + + # Adjust signal handling inside the start method + signal.signal(signal.SIGTERM, self.signal_handler) + signal.signal(signal.SIGINT, self.signal_handler) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logging.info("Closing NEBULA (exiting from components)... Please wait") + observer.stop() + self.stop() + + observer.join() + + def signal_handler(self, sig, frame): + """ + Handler for termination signals (SIGTERM, SIGINT). + + - Logs signal reception. + - Executes a graceful shutdown by calling self.stop(). + - Exits the process with sys.exit(0). + + Parameters: + - sig: The signal number received. + - frame: The current stack frame at signal reception. + """ + logging.info("Received termination signal, shutting down...") + self.stop() + sys.exit(0) + + def run_controller_api(self): + """ + Runs the FastAPI controller application using Uvicorn. + + - Binds to all network interfaces (0.0.0.0). + - Uses the port specified in self.controller_port. + - Disables Uvicorn's default logging configuration to use custom logging. + """ + uvicorn.run( + self.app, + host="0.0.0.0", + port=self.controller_port, + log_config=None, # Prevent Uvicorn from configuring logging + ) + + def run_waf(self): + """ + Starts the Web Application Firewall (WAF) and related monitoring containers. + + - Creates a Docker network named based on the current user. + - Starts the 'nebula-waf' container with logs volume and port mapping. + - Starts the 'nebula-waf-grafana' container for monitoring dashboards, + setting environment variables for Grafana configuration. + - Starts the 'nebula-waf-loki' container for log aggregation with a config file. + - Starts the 'nebula-waf-promtail' container to collect logs from nginx. + + All containers are connected to the same Docker network with assigned static IPs. + """ + network_name = f"{os.environ['USER']}_nebula-net-base" + base = DockerUtils.create_docker_network(network_name) + + client = docker.from_env() + + volumes_waf = ["/var/log/nginx"] + + ports_waf = [80] + + host_config_waf = client.api.create_host_config( + binds=[f"{os.environ['NEBULA_LOGS_DIR']}/waf/nginx:/var/log/nginx"], + privileged=True, + port_bindings={80: self.waf_port}, + ) + + networking_config_waf = client.api.create_networking_config({ + f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.200") + }) + + container_id_waf = client.api.create_container( + image="nebula-waf", + name=f"{os.environ['USER']}_nebula-waf", + detach=True, + volumes=volumes_waf, + host_config=host_config_waf, + networking_config=networking_config_waf, + ports=ports_waf, + ) + + client.api.start(container_id_waf) + + environment = { + "GF_SECURITY_ADMIN_PASSWORD": "admin", + "GF_USERS_ALLOW_SIGN_UP": "false", + "GF_SERVER_HTTP_PORT": "3000", + "GF_SERVER_PROTOCOL": "http", + "GF_SERVER_DOMAIN": f"localhost:{self.grafana_port}", + "GF_SERVER_ROOT_URL": f"http://localhost:{self.grafana_port}/grafana/", + "GF_SERVER_SERVE_FROM_SUB_PATH": "true", + "GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH": "/var/lib/grafana/dashboards/dashboard.json", + "GF_METRICS_MAX_LIMIT_TSDB": "0", + } + + ports = [3000] + + host_config = client.api.create_host_config( + port_bindings={3000: self.grafana_port}, + ) + + networking_config = client.api.create_networking_config({ + f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.201") + }) + + container_id = client.api.create_container( + image="nebula-waf-grafana", + name=f"{os.environ['USER']}_nebula-waf-grafana", + detach=True, + environment=environment, + host_config=host_config, + networking_config=networking_config, + ports=ports, + ) + + client.api.start(container_id) + + command = ["-config.file=/mnt/config/loki-config.yml"] + + ports_loki = [3100] + + host_config_loki = client.api.create_host_config( + port_bindings={3100: self.loki_port}, + ) + + networking_config_loki = client.api.create_networking_config({ + f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.202") + }) + + container_id_loki = client.api.create_container( + image="nebula-waf-loki", + name=f"{os.environ['USER']}_nebula-waf-loki", + detach=True, + command=command, + host_config=host_config_loki, + networking_config=networking_config_loki, + ports=ports_loki, + ) + + client.api.start(container_id_loki) + + volumes_promtail = ["/var/log/nginx"] + + host_config_promtail = client.api.create_host_config( + binds=[ + f"{os.environ['NEBULA_LOGS_DIR']}/waf/nginx:/var/log/nginx", + ], + ) + + networking_config_promtail = client.api.create_networking_config({ + f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.203") + }) + + container_id_promtail = client.api.create_container( + image="nebula-waf-promtail", + name=f"{os.environ['USER']}_nebula-waf-promtail", + detach=True, + volumes=volumes_promtail, + host_config=host_config_promtail, + networking_config=networking_config_promtail, + ) + + client.api.start(container_id_promtail) + + def run_frontend(self): + """ + Starts the NEBULA frontend Docker container. + + - Checks if Docker is running (different checks for Windows and Unix). + - Detects if an NVIDIA GPU is available and sets a flag. + - Creates a Docker network named based on the current user. + - Prepares environment variables and volume mounts for the container. + - Binds ports for HTTP (80) and statistics (8080). + - Starts the 'nebula-frontend' container connected to the created network + with static IP assignment. + """ + if sys.platform == "win32": + if not os.path.exists("//./pipe/docker_Engine"): + raise Exception( + "Docker is not running, please check if Docker is running and Docker Compose is installed." + ) + else: + if not os.path.exists("/var/run/docker.sock"): + raise Exception( + "/var/run/docker.sock not found, please check if Docker is running and Docker Compose is installed." + ) + + try: + subprocess.check_call(["nvidia-smi"]) + self.gpu_available = True + except Exception: + logging.info("No GPU available for the frontend, nodes will be deploy in CPU mode") + + network_name = f"{os.environ['USER']}_nebula-net-base" + + # Create the Docker network + base = DockerUtils.create_docker_network(network_name) + + client = docker.from_env() + + environment = { + "NEBULA_CONTROLLER_NAME": os.environ["USER"], + "NEBULA_PRODUCTION": self.production, + "NEBULA_GPU_AVAILABLE": self.gpu_available, + "NEBULA_ADVANCED_ANALYTICS": self.advanced_analytics, + "NEBULA_FRONTEND_LOG": "/nebula/app/logs/frontend.log", + "NEBULA_LOGS_DIR": "/nebula/app/logs/", + "NEBULA_CONFIG_DIR": "/nebula/app/config/", + "NEBULA_CERTS_DIR": "/nebula/app/certs/", + "NEBULA_ENV_PATH": "/nebula/app/.env", + "NEBULA_ROOT_HOST": self.root_path, + "NEBULA_HOST_PLATFORM": self.host_platform, + "NEBULA_DEFAULT_USER": "admin", + "NEBULA_DEFAULT_PASSWORD": "admin", + "NEBULA_FRONTEND_PORT": self.frontend_port, + "NEBULA_CONTROLLER_PORT": self.controller_port, + "NEBULA_CONTROLLER_HOST": "host.docker.internal", + } + + volumes = ["/nebula", "/var/run/docker.sock", "/etc/nginx/sites-available/default"] + + ports = [80, 8080] + + host_config = client.api.create_host_config( + binds=[ + f"{self.root_path}:/nebula", + "/var/run/docker.sock:/var/run/docker.sock", + f"{self.root_path}/nebula/frontend/config/nebula:/etc/nginx/sites-available/default", + ], + extra_hosts={"host.docker.internal": "host-gateway"}, + port_bindings={80: self.frontend_port, 8080: self.statistics_port}, + ) + + networking_config = client.api.create_networking_config({ + f"{network_name}": client.api.create_endpoint_config(ipv4_address=f"{base}.100") + }) + + container_id = client.api.create_container( + image="nebula-frontend", + name=f"{os.environ['USER']}_nebula-frontend", + detach=True, + environment=environment, + volumes=volumes, + host_config=host_config, + networking_config=networking_config, + ports=ports, + ) + + client.api.start(container_id) + + @staticmethod + def stop_waf(): + """ + Stops all running Docker containers whose names start with + the pattern '_nebula-waf'. + + This is used to cleanly shut down the WAF-related containers. + """ + DockerUtils.remove_containers_by_prefix(f"{os.environ['USER']}_nebula-waf") + + @staticmethod + def stop(): + """ + Gracefully shuts down the entire NEBULA system by performing the following steps: + + - Logs the shutdown initiation. + - Removes all Docker containers with names starting with '_'. + - Stops blockchain services and participant nodes via ScenarioManagement. + - Stops the WAF containers by calling stop_waf(). + - Removes Docker networks with names starting with '_'. + - Attempts to kill the controller process using its PID stored in 'controller.pid'. + - Handles any exceptions during PID reading or killing by logging them. + - Exits the program with status code 0. + """ + logging.info("Closing NEBULA (exiting from components)... Please wait") + DockerUtils.remove_containers_by_prefix(f"{os.environ['USER']}_") + ScenarioManagement.stop_blockchain() + ScenarioManagement.stop_participants() + Controller.stop_waf() + DockerUtils.remove_docker_networks_by_prefix(f"{os.environ['USER']}_") + controller_pid_file = os.path.join(os.path.dirname(__file__), "controller.pid") + try: + with open(controller_pid_file) as f: + pid = int(f.read()) + os.kill(pid, signal.SIGKILL) + os.remove(controller_pid_file) + except Exception as e: + logging.exception(f"Error while killing controller process: {e}") + sys.exit(0) diff --git a/nebula/frontend/database.py b/nebula/controller/database.py similarity index 63% rename from nebula/frontend/database.py rename to nebula/controller/database.py index 3a2a731e2..02886d89a 100755 --- a/nebula/frontend/database.py +++ b/nebula/controller/database.py @@ -1,15 +1,17 @@ import asyncio import datetime +import json import logging +import os import sqlite3 import aiosqlite from argon2 import PasswordHasher -user_db_file_location = "databases/users.db" -node_db_file_location = "databases/nodes.db" -scenario_db_file_location = "databases/scenarios.db" -notes_db_file_location = "databases/notes.db" +user_db_file_location = "users.db" +node_db_file_location = "nodes.db" +scenario_db_file_location = "scenarios.db" +notes_db_file_location = "notes.db" _node_lock = asyncio.Lock() @@ -44,7 +46,14 @@ async def ensure_columns(conn, table_name, desired_columns): await conn.commit() -async def initialize_databases(): +async def initialize_databases(databases_dir): + global user_db_file_location, node_db_file_location, scenario_db_file_location, notes_db_file_location + + user_db_file_location = os.path.join(databases_dir, user_db_file_location) + node_db_file_location = os.path.join(databases_dir, node_db_file_location) + scenario_db_file_location = os.path.join(databases_dir, scenario_db_file_location) + notes_db_file_location = os.path.join(databases_dir, notes_db_file_location) + await setup_database(user_db_file_location) await setup_database(node_db_file_location) await setup_database(scenario_db_file_location) @@ -111,11 +120,45 @@ async def initialize_databases(): end_time TEXT, title TEXT, description TEXT, - status TEXT, - network_subnet TEXT, - model TEXT, + deployment TEXT, + federation TEXT, + topology TEXT, + nodes TEXT, + nodes_graph TEXT, + n_nodes TEXT, + matrix TEXT, + random_topology_probability TEXT, dataset TEXT, + iid TEXT, + partition_selection TEXT, + partition_parameter TEXT, + model TEXT, + agg_algorithm TEXT, rounds TEXT, + logginglevel TEXT, + report_status_data_queue TEXT, + accelerator TEXT, + network_subnet TEXT, + network_gateway TEXT, + epochs TEXT, + attacks TEXT, + poisoned_node_percent TEXT, + poisoned_sample_percent TEXT, + poisoned_noise_percent TEXT, + attack_params TEXT, + with_reputation TEXT, + random_geo TEXT, + latitude TEXT, + longitude TEXT, + mobility TEXT, + mobility_type TEXT, + radius_federation TEXT, + scheme_mobility TEXT, + round_frequency TEXT, + mobile_participants_percent TEXT, + additional_participants TEXT, + schema_additional_participants TEXT, + status TEXT, role TEXT, username TEXT, gpu_id TEXT @@ -128,14 +171,48 @@ async def initialize_databases(): "end_time": "TEXT", "title": "TEXT", "description": "TEXT", - "status": "TEXT", - "network_subnet": "TEXT", - "model": "TEXT", + "deployment": "TEXT", + "federation": "TEXT", + "topology": "TEXT", + "nodes": "TEXT", + "nodes_graph": "TEXT", + "n_nodes": "TEXT", + "matrix": "TEXT", + "random_topology_probability": "TEXT", "dataset": "TEXT", + "iid": "TEXT", + "partition_selection": "TEXT", + "partition_parameter": "TEXT", + "model": "TEXT", + "agg_algorithm": "TEXT", "rounds": "TEXT", - "role": "TEXT", - "username": "TEXT", + "logginglevel": "TEXT", + "report_status_data_queue": "TEXT", + "accelerator": "TEXT", "gpu_id": "TEXT", + "network_subnet": "TEXT", + "network_gateway": "TEXT", + "epochs": "TEXT", + "attacks": "TEXT", + "poisoned_node_percent": "TEXT", + "poisoned_sample_percent": "TEXT", + "poisoned_noise_percent": "TEXT", + "attack_params": "TEXT", + "with_reputation": "TEXT", + "random_geo": "TEXT", + "latitude": "TEXT", + "longitude": "TEXT", + "mobility": "TEXT", + "mobility_type": "TEXT", + "radius_federation": "TEXT", + "scheme_mobility": "TEXT", + "round_frequency": "TEXT", + "mobile_participants_percent": "TEXT", + "additional_participants": "TEXT", + "schema_additional_participants": "TEXT", + "status": "TEXT", + "role": "TEXT", + "username": "TEXT" } await ensure_columns(conn, "scenarios", desired_columns) @@ -151,6 +228,14 @@ async def initialize_databases(): desired_columns = {"scenario": "TEXT PRIMARY KEY", "scenario_notes": "TEXT"} await ensure_columns(conn, "notes", desired_columns) + + username = os.environ.get("NEBULA_DEFAULT_USER", "admin") + password = os.environ.get("NEBULA_DEFAULT_PASSWORD", "admin") + if not list_users(): + add_user(username, password, "admin") + if not verify_hash_algorithm(username): + update_user(username, password, "admin") + def list_users(all_info=False): with sqlite3.connect(user_db_file_location) as conn: @@ -415,25 +500,41 @@ def get_all_scenarios_and_check_completed(username, role, sort_by="start_time"): if role == "admin": if sort_by == "start_time": command = """ - SELECT * FROM scenarios - ORDER BY strftime('%Y-%m-%d %H:%M:%S', substr(start_time, 7, 4) || '-' || substr(start_time, 4, 2) || '-' || substr(start_time, 1, 2) || ' ' || substr(start_time, 12, 8)); + SELECT name, username, title, start_time, model, dataset, rounds, status FROM scenarios + ORDER BY + CASE + WHEN start_time IS NULL OR start_time = '' THEN 1 + ELSE 0 + END, + strftime( + '%Y-%m-%d %H:%M:%S', + substr(start_time, 7, 4) || '-' || substr(start_time, 4, 2) || '-' || substr(start_time, 1, 2) || ' ' || substr(start_time, 12, 8) + ); """ _c.execute(command) else: - command = "SELECT * FROM scenarios ORDER BY ?;" + command = "SELECT name, username, title, start_time, model, dataset, rounds, status FROM scenarios ORDER BY ?;" _c.execute(command, (sort_by,)) # _c.execute(command) result = _c.fetchall() else: if sort_by == "start_time": command = """ - SELECT * FROM scenarios + SELECT name, username, title, start_time, model, dataset, rounds, status FROM scenarios WHERE username = ? - ORDER BY strftime('%Y-%m-%d %H:%M:%S', substr(start_time, 7, 4) || '-' || substr(start_time, 4, 2) || '-' || substr(start_time, 1, 2) || ' ' || substr(start_time, 12, 8)); + ORDER BY + CASE + WHEN start_time IS NULL OR start_time = '' THEN 1 + ELSE 0 + END, + strftime( + '%Y-%m-%d %H:%M:%S', + substr(start_time, 7, 4) || '-' || substr(start_time, 4, 2) || '-' || substr(start_time, 1, 2) || ' ' || substr(start_time, 12, 8) + ); """ _c.execute(command, (username,)) else: - command = "SELECT * FROM scenarios WHERE username = ? ORDER BY ?;" + command = "SELECT name, username, title, start_time, model, dataset, rounds, status FROM scenarios WHERE username = ? ORDER BY ?;" _c.execute( command, ( @@ -454,67 +555,225 @@ def get_all_scenarios_and_check_completed(username, role, sort_by="start_time"): def scenario_update_record( - scenario_name, - username, + name, start_time, end_time, - title, - description, + scenario, status, - network_subnet, - model, - dataset, - rounds, role, - gpu_id, + username ): _conn = sqlite3.connect(scenario_db_file_location) _c = _conn.cursor() - command = "SELECT * FROM scenarios WHERE name = ?;" - _c.execute(command, (scenario_name,)) + select_command = "SELECT * FROM scenarios WHERE name = ?;" + _c.execute(select_command, (name,)) result = _c.fetchone() if result is None: - # Create a new record - _c.execute( - "INSERT INTO scenarios VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - scenario_name, + insert_command = """ + INSERT INTO scenarios ( + name, start_time, end_time, title, description, - status, - network_subnet, - model, + deployment, + federation, + topology, + nodes, + nodes_graph, + n_nodes, + matrix, + random_topology_probability, dataset, + iid, + partition_selection, + partition_parameter, + model, + agg_algorithm, rounds, - role, - username, + logginglevel, + report_status_data_queue, + accelerator, gpu_id, - ), - ) - else: - # Update the record - command = "UPDATE scenarios SET start_time = ?, end_time = ?, title = ?, description = ?, status = ?, network_subnet = ?, model = ?, dataset = ?, rounds = ?, role = ? WHERE name = ?;" - _c.execute( - command, - ( - start_time, - end_time, - title, - description, - status, network_subnet, - model, - dataset, - rounds, + network_gateway, + epochs, + attacks, + poisoned_node_percent, + poisoned_sample_percent, + poisoned_noise_percent, + attack_params, + with_reputation, + random_geo, + latitude, + longitude, + mobility, + mobility_type, + radius_federation, + scheme_mobility, + round_frequency, + mobile_participants_percent, + additional_participants, + schema_additional_participants, + status, role, - scenario_name, - ), - ) - + username + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ); + """ + _c.execute(insert_command, ( + name, + start_time, + end_time, + scenario.scenario_title, + scenario.scenario_description, + scenario.deployment, + scenario.federation, + scenario.topology, + json.dumps(scenario.nodes), + json.dumps(scenario.nodes_graph), + scenario.n_nodes, + json.dumps(scenario.matrix), + scenario.random_topology_probability, + scenario.dataset, + scenario.iid, + scenario.partition_selection, + scenario.partition_parameter, + scenario.model, + scenario.agg_algorithm, + scenario.rounds, + scenario.logginglevel, + scenario.report_status_data_queue, + scenario.accelerator, + json.dumps(scenario.gpu_id), + scenario.network_subnet, + scenario.network_gateway, + scenario.epochs, + json.dumps(scenario.attacks), + scenario.poisoned_node_percent, + scenario.poisoned_sample_percent, + scenario.poisoned_noise_percent, + json.dumps(scenario.attack_params), + scenario.with_reputation, + scenario.random_geo, + scenario.latitude, + scenario.longitude, + scenario.mobility, + scenario.mobility_type, + scenario.radius_federation, + scenario.scheme_mobility, + scenario.round_frequency, + scenario.mobile_participants_percent, + json.dumps(scenario.additional_participants), + scenario.schema_additional_participants, + status, + role, + username + )) + else: + update_command = """ + UPDATE scenarios SET + start_time = ?, + end_time = ?, + title = ?, + description = ?, + deployment = ?, + federation = ?, + topology = ?, + nodes = ?, + nodes_graph = ?, + n_nodes = ?, + matrix = ?, + random_topology_probability = ?, + dataset = ?, + iid = ?, + partition_selection = ?, + partition_parameter = ?, + model = ?, + agg_algorithm = ?, + rounds = ?, + logginglevel = ?, + report_status_data_queue = ?, + accelerator = ?, + gpu_id = ?, + network_subnet = ?, + network_gateway = ?, + epochs = ?, + attacks = ?, + poisoned_node_percent = ?, + poisoned_sample_percent = ?, + poisoned_noise_percent = ?, + attack_params = ?, + with_reputation = ?, + random_geo = ?, + latitude = ?, + longitude = ?, + mobility = ?, + mobility_type = ?, + radius_federation = ?, + scheme_mobility = ?, + round_frequency = ?, + mobile_participants_percent = ?, + additional_participants = ?, + schema_additional_participants = ?, + status = ?, + role = ?, + username = ? + WHERE name = ?; + """ + _c.execute(update_command, ( + start_time, + end_time, + scenario.scenario_title, + scenario.scenario_description, + scenario.deployment, + scenario.federation, + scenario.topology, + json.dumps(scenario.nodes), + json.dumps(scenario.nodes_graph), + scenario.n_nodes, + json.dumps(scenario.matrix), + scenario.random_topology_probability, + scenario.dataset, + scenario.iid, + scenario.partition_selection, + scenario.partition_parameter, + scenario.model, + scenario.agg_algorithm, + scenario.rounds, + scenario.logginglevel, + scenario.report_status_data_queue, + scenario.accelerator, + json.dumps(scenario.gpu_id), + scenario.network_subnet, + scenario.network_gateway, + scenario.epochs, + json.dumps(scenario.attacks), + scenario.poisoned_node_percent, + scenario.poisoned_sample_percent, + scenario.poisoned_noise_percent, + json.dumps(scenario.attack_params), + scenario.with_reputation, + scenario.random_geo, + scenario.latitude, + scenario.longitude, + scenario.mobility, + scenario.mobility_type, + scenario.radius_federation, + scenario.scheme_mobility, + scenario.round_frequency, + scenario.mobile_participants_percent, + json.dumps(scenario.additional_participants), + scenario.schema_additional_participants, + status, + role, + username, + name + )) + _conn.commit() _conn.close() diff --git a/nebula/scenarios.py b/nebula/controller/scenarios.py similarity index 93% rename from nebula/scenarios.py rename to nebula/controller/scenarios.py index 1e1f37a9f..ba3e93b22 100644 --- a/nebula/scenarios.py +++ b/nebula/controller/scenarios.py @@ -15,7 +15,6 @@ from nebula.addons.blockchain.blockchain_deployer import BlockchainDeployer from nebula.addons.topologymanager import TopologyManager -from nebula.config.config import Config from nebula.core.datasets.cifar10.cifar10 import CIFAR10Dataset from nebula.core.datasets.cifar100.cifar100 import CIFAR100Dataset from nebula.core.datasets.emnist.emnist import EMNISTDataset @@ -23,6 +22,7 @@ from nebula.core.datasets.mnist.mnist import MNISTDataset from nebula.core.utils.certificate import generate_ca_certificate, generate_certificate from nebula.utils import DockerUtils, FileUtils +from nebula.config.config import Config # Definition of a scenario @@ -70,13 +70,11 @@ def __init__( weight_model_similarity, weight_num_messages, weight_fraction_params_changed, - # is_dynamic_topology, - # is_dynamic_aggregation, - # target_aggregation, random_geo, latitude, longitude, mobility, + network_simulation, mobility_type, radius_federation, scheme_mobility, @@ -85,13 +83,19 @@ def __init__( additional_participants, schema_additional_participants, random_topology_probability, + with_sa, + strict_topology, + sad_candidate_selector, + sad_model_handler, + sar_arbitration_policy, + sar_neighbor_policy, ): """ Initialize the scenario. Args: - scenario_title (str): Title of the scenario. - scenario_description (str): Description of the scenario. + title (str): Title of the scenario. + description (str): Description of the scenario. deployment (str): Type of deployment (e.g., 'docker', 'process'). federation (str): Type of federation. topology (str): Network topology. @@ -129,9 +133,6 @@ def __init__( weight_model_similarity (float): Weight of model similarity. weight_num_messages (float): Weight of number of messages. weight_fraction_params_changed (float): Weight of fraction of parameters changed. - # is_dynamic_topology (bool): Indicator if topology is dynamic. - # is_dynamic_aggregation (bool): Indicator if aggregation is dynamic. - # target_aggregation (str): Target aggregation method. random_geo (bool): Indicator if random geo is used. latitude (float): Latitude for mobility. longitude (float): Longitude for mobility. @@ -144,6 +145,12 @@ def __init__( additional_participants (list): List of additional participants. schema_additional_participants (str): Schema for additional participants. random_topology_probability (float): Probability for random topology. + with_sa (bool) : Indicator if Situational Awareness is used. + strict_topology (bool) : + sad_candidate_selector (str) : + sad_model_handler (str) : + sar_arbitration_policy (str) : + sar_neighbor_policy (str) : """ self.scenario_title = scenario_title self.scenario_description = scenario_description @@ -181,13 +188,11 @@ def __init__( self.weight_model_similarity = weight_model_similarity self.weight_num_messages = weight_num_messages self.weight_fraction_params_changed = weight_fraction_params_changed - # self.is_dynamic_topology = is_dynamic_topology - # self.is_dynamic_aggregation = is_dynamic_aggregation - # self.target_aggregation = target_aggregation self.random_geo = random_geo self.latitude = latitude self.longitude = longitude self.mobility = mobility + self.network_simulation = network_simulation self.mobility_type = mobility_type self.radius_federation = radius_federation self.scheme_mobility = scheme_mobility @@ -196,6 +201,12 @@ def __init__( self.additional_participants = additional_participants self.schema_additional_participants = schema_additional_participants self.random_topology_probability = random_topology_probability + self.with_sa = with_sa + self.strict_topology = strict_topology + self.sad_candidate_selector = sad_candidate_selector + self.sad_model_handler = sad_model_handler + self.sar_arbitration_policy = sar_arbitration_policy + self.sar_neighbor_policy = sar_neighbor_policy def attack_node_assign( self, @@ -438,9 +449,9 @@ def __init__(self, scenario, user=None): # Assign the controller endpoint if self.scenario.deployment == "docker": - self.controller = f"{os.environ.get('NEBULA_CONTROLLER_NAME')}_nebula-frontend" + self.controller = f"{os.environ.get('NEBULA_CONTROLLER_HOST')}:{os.environ.get('NEBULA_CONTROLLER_PORT')}" else: - self.controller = f"127.0.0.1:{os.environ.get('NEBULA_FRONTEND_PORT')}" + self.controller = f"127.0.0.1:{os.environ.get('NEBULA_CONTROLLER_PORT')}" self.topologymanager = None self.env_path = None @@ -504,7 +515,7 @@ def __init__(self, scenario, user=None): shutil.copy( os.path.join( os.path.dirname(__file__), - "./frontend/config/participant.json.example", + "../frontend/config/participant.json.example", ), participant_file, ) @@ -514,6 +525,7 @@ def __init__(self, scenario, user=None): participant_config["network_args"]["ip"] = node_config["ip"] participant_config["network_args"]["port"] = int(node_config["port"]) + participant_config["network_args"]["simulation"] = self.scenario.network_simulation participant_config["device_args"]["idx"] = node_config["id"] participant_config["device_args"]["start"] = node_config["start"] participant_config["device_args"]["role"] = node_config["role"] @@ -533,9 +545,6 @@ def __init__(self, scenario, user=None): participant_config["adversarial_args"]["attacks"] = node_config["attacks"] participant_config["adversarial_args"]["attack_params"] = node_config["attack_params"] participant_config["defense_args"]["with_reputation"] = node_config["with_reputation"] - # participant_config["defense_args"]["is_dynamic_topology"] = self.scenario.is_dynamic_topology - # participant_config["defense_args"]["is_dynamic_aggregation"] = self.scenario.is_dynamic_aggregation - # participant_config["defense_args"]["target_aggregation"] = self.scenario.target_aggregation participant_config["defense_args"]["reputation_metrics"] = self.scenario.reputation_metrics participant_config["defense_args"]["initial_reputation"] = self.scenario.initial_reputation participant_config["defense_args"]["weighting_factor"] = self.scenario.weighting_factor @@ -557,6 +566,21 @@ def __init__(self, scenario, user=None): participant_config["mobility_args"]["round_frequency"] = self.scenario.round_frequency participant_config["reporter_args"]["report_status_data_queue"] = self.scenario.report_status_data_queue participant_config["mobility_args"]["topology_type"] = self.scenario.topology + if self.scenario.with_sa: + participant_config["situational_awareness"] = { + "strict_topology": self.scenario.strict_topology, + "sa_discovery": { + "candidate_selector": self.scenario.sad_candidate_selector, + "model_handler": self.scenario.sad_model_handler, + "verbose": True, + }, + "sa_reasoner": { + "arbitration_policy": self.scenario.sar_arbitration_policy, + "verbose": True, + "sar_components": {"sa_network": True}, + "sa_network": {"neighbor_policy": self.scenario.sar_neighbor_policy, "verbose": True}, + }, + } with open(participant_file, "w") as f: json.dump(participant_config, f, sort_keys=False, indent=2) @@ -962,7 +986,7 @@ def start_nodes_docker(self): command = [ "/bin/bash", "-c", - f"{start_command} && ifconfig && echo '{base}.1 host.docker.internal' >> /etc/hosts && python /nebula/nebula/node.py /nebula/app/config/{self.scenario_name}/participant_{node['device_args']['idx']}.json", + f"{start_command} && ifconfig && echo '{base}.1 host.docker.internal' >> /etc/hosts && python /nebula/nebula/core/node.py /nebula/app/config/{self.scenario_name}/participant_{node['device_args']['idx']}.json", ] if self.use_blockchain: @@ -1016,28 +1040,29 @@ def start_nodes_docker(self): i += 1 def start_nodes_process(self): + self.processes_root_path = os.path.join(os.path.dirname(__file__),"..", "..") logging.info("Starting nodes as processes...") logging.info(f"env path: {self.env_path}") # Include additional config to the participants for idx, node in enumerate(self.config.participants): - node["tracking_args"]["log_dir"] = os.path.join(self.root_path, "app", "logs") - node["tracking_args"]["config_dir"] = os.path.join(self.root_path, "app", "config", self.scenario_name) + node["tracking_args"]["log_dir"] = os.path.join(self.processes_root_path, "app", "logs") + node["tracking_args"]["config_dir"] = os.path.join(self.processes_root_path, "app", "config", self.scenario_name) node["scenario_args"]["controller"] = self.controller node["scenario_args"]["deployment"] = self.scenario.deployment node["security_args"]["certfile"] = os.path.join( - self.root_path, + self.processes_root_path, "app", "certs", f"participant_{node['device_args']['idx']}_cert.pem", ) node["security_args"]["keyfile"] = os.path.join( - self.root_path, + self.processes_root_path, "app", "certs", f"participant_{node['device_args']['idx']}_key.pem", ) - node["security_args"]["cafile"] = os.path.join(self.root_path, "app", "certs", "ca_cert.pem") + node["security_args"]["cafile"] = os.path.join(self.processes_root_path, "app", "certs", "ca_cert.pem") # Write the config file in config directory with open(f"{self.config_dir}/participant_{node['device_args']['idx']}.json", "w") as f: @@ -1063,11 +1088,11 @@ def start_nodes_process(self): commands += "Start-Sleep -Seconds 2\n" commands += f'Write-Host "Running node {node["device_args"]["idx"]}..."\n' - commands += f'$OUT_FILE = "{self.root_path}\\app\\logs\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.out"\n' - commands += f'$ERROR_FILE = "{self.root_path}\\app\\logs\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.err"\n' + commands += f'$OUT_FILE = "{self.processes_root_path}\\app\\logs\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.out"\n' + commands += f'$ERROR_FILE = "{self.processes_root_path}\\app\\logs\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.err"\n' # Use Start-Process for executing Python in background and capture PID - commands += f"""$process = Start-Process -FilePath "python" -ArgumentList "{self.root_path}\\nebula\\node.py {self.root_path}\\app\\config\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.json" -PassThru -NoNewWindow -RedirectStandardOutput $OUT_FILE -RedirectStandardError $ERROR_FILE + commands += f"""$process = Start-Process -FilePath "python" -ArgumentList "{self.processes_root_path}\\nebula\\core\\node.py {self.processes_root_path}\\app\\config\\{self.scenario_name}\\participant_{node["device_args"]["idx"]}.json" -PassThru -NoNewWindow -RedirectStandardOutput $OUT_FILE -RedirectStandardError $ERROR_FILE Add-Content -Path $PID_FILE -Value $process.Id """ @@ -1089,15 +1114,15 @@ def start_nodes_process(self): else: commands += "sleep 2\n" commands += f'echo "Running node {node["device_args"]["idx"]}..."\n' - commands += f"OUT_FILE={self.root_path}/app/logs/{self.scenario_name}/participant_{node['device_args']['idx']}.out\n" - commands += f"python {self.root_path}/nebula/node.py {self.root_path}/app/config/{self.scenario_name}/participant_{node['device_args']['idx']}.json > $OUT_FILE 2>&1 &\n" + commands += f"OUT_FILE={self.processes_root_path}/app/logs/{self.scenario_name}/participant_{node['device_args']['idx']}.out\n" + commands += f"python {self.processes_root_path}/nebula/core/node.py {self.processes_root_path}/app/config/{self.scenario_name}/participant_{node['device_args']['idx']}.json > $OUT_FILE 2>&1 &\n" commands += "echo $! >> $PID_FILE\n\n" commands += 'echo "All nodes started. PIDs stored in $PID_FILE"\n' - with open(f"/nebula/app/config/{self.scenario_name}/current_scenario_commands.sh", "w") as f: + with open(f"{self.processes_root_path}/app/config/{self.scenario_name}/current_scenario_commands.sh", "w") as f: f.write(commands) - os.chmod(f"/nebula/app/config/{self.scenario_name}/current_scenario_commands.sh", 0o755) + os.chmod(f"{self.processes_root_path}/app/config/{self.scenario_name}/current_scenario_commands.sh", 0o755) except Exception as e: raise Exception(f"Error starting nodes as processes: {e}") @@ -1233,4 +1258,4 @@ def generate_statistics(cls, path): except Exception as e: logging.exception(f"Error generating statistics: {e}") - return False + return False \ No newline at end of file diff --git a/nebula/core/addonmanager.py b/nebula/core/addonmanager.py index 6f79c15de..e55d96465 100644 --- a/nebula/core/addonmanager.py +++ b/nebula/core/addonmanager.py @@ -9,7 +9,7 @@ from nebula.core.engine import Engine -class AddonManager: +class AddondManager: def __init__(self, engine: "Engine", config): self._engine = engine self._config = config @@ -18,19 +18,17 @@ def __init__(self, engine: "Engine", config): async def deploy_additional_services(self): print_msg_box(msg="Deploying Additional Services", indent=2, title="Addons Manager") if self._config.participant["mobility_args"]["mobility"]: - mobility = Mobility(self._config, self._engine.cm, verbose=False) + mobility = Mobility(self._config, verbose=False) self._addons.append(mobility) - if self._config.participant["network_args"]["simulation"]: - refresh_conditions_interval = 5 - network_simulation = factory_network_simulator( - "nebula", self._engine.cm, refresh_conditions_interval, "eth0", verbose=False - ) - self._addons.append(network_simulation) - update_interval = 5 gps = factory_gpsmodule("nebula", self._config, self._engine.addr, update_interval, verbose=False) self._addons.append(gps) + if self._config.participant["network_args"]["simulation"]: + refresh_conditions_interval = 5 + network_simulation = factory_network_simulator("nebula", refresh_conditions_interval, "eth0", verbose=False) + self._addons.append(network_simulation) + for add in self._addons: await add.start() diff --git a/nebula/core/aggregation/aggregator.py b/nebula/core/aggregation/aggregator.py index 8bdd32ee4..2489070aa 100755 --- a/nebula/core/aggregation/aggregator.py +++ b/nebula/core/aggregation/aggregator.py @@ -44,6 +44,7 @@ def __init__(self, config=None, engine=None): logging.info(f"[{self.__class__.__name__}] Starting Aggregator") self._federation_nodes = set() self._pending_models_to_aggregate = {} + self._pending_models_to_aggregate_lock = Locker(name="pending_models_to_aggregate_lock", async_lock=True) self._aggregation_done_lock = Locker(name="aggregation_done_lock", async_lock=True) self._aggregation_waiting_skip = asyncio.Event() @@ -91,7 +92,7 @@ async def get_aggregation(self): await self.us.notify_if_all_updates_received() lock_task = asyncio.create_task(self._aggregation_done_lock.acquire_async(timeout=timeout)) skip_task = asyncio.create_task(self._aggregation_waiting_skip.wait()) - done, _ = await asyncio.wait( + done, pending = await asyncio.wait( [lock_task, skip_task], return_when=asyncio.FIRST_COMPLETED, ) @@ -119,7 +120,6 @@ async def get_aggregation(self): await self.us.stop_notifying_updates() updates = await self.us.get_round_updates() missing_nodes = await self.us.get_round_missing_nodes() - if missing_nodes: logging.info(f"πŸ”„ get_aggregation | Aggregation incomplete, missing models from: {missing_nodes}") else: diff --git a/nebula/core/aggregation/updatehandlers/cflupdatehandler.py b/nebula/core/aggregation/updatehandlers/cflupdatehandler.py index 29402629e..574e24505 100644 --- a/nebula/core/aggregation/updatehandlers/cflupdatehandler.py +++ b/nebula/core/aggregation/updatehandlers/cflupdatehandler.py @@ -118,11 +118,12 @@ async def get_round_updates(self) -> dict[str, tuple[object, float]]: if updates_missing: self._missing_ones = updates_missing logging.info(f"Missing updates from sources: {updates_missing}") - else: - self._missing_ones.clear() updates = {} for sr in self._sources_received: - if self._role == "trainer" and len(self._sources_received) > 1 and sr == self._addr: # if trainer node ignore self updt if has received udpate from server + if ( + self._role == "trainer" and len(self._sources_received) > 1 + ): # if trainer node ignore self updt if has received udpate from server + if sr == self._addr: continue source_historic = self.us[sr] updt: Update = None diff --git a/nebula/core/aggregation/updatehandlers/dflupdatehandler.py b/nebula/core/aggregation/updatehandlers/dflupdatehandler.py index 6c1a6c405..b6db71cb8 100644 --- a/nebula/core/aggregation/updatehandlers/dflupdatehandler.py +++ b/nebula/core/aggregation/updatehandlers/dflupdatehandler.py @@ -38,7 +38,7 @@ def __init__(self, aggregator, addr, buffersize=MAX_UPDATE_BUFFER_SIZE): self._sources_received = set() self._round_updates_lock = Locker( name="round_updates_lock", async_lock=True - ) # It is taken when you start to check if all the updates are available. + ) # se coge cuando se empieza a comprobar si estan todas las updates self._update_federation_lock = Locker(name="update_federation_lock", async_lock=True) self._notification_sent_lock = Locker(name="notification_sent_lock", async_lock=True) self._notification = False @@ -69,7 +69,7 @@ async def round_expected_updates(self, federation_nodes: set): self.us[fn] = (None, deque(maxlen=self._buffersize)) # Clear removed nodes - removed_nodes = [node for node in self._updates_storage if node not in federation_nodes] + removed_nodes = [node for node in self._updates_storage.keys() if node not in federation_nodes] for rn in removed_nodes: del self._updates_storage[rn] @@ -90,16 +90,21 @@ async def _check_updates_already_received(self): (last_updt, node_storage) = self._updates_storage[se] if len(node_storage): try: - if (last_updt and node_storage[-1] and last_updt != node_storage[-1]) or (node_storage[-1] and not last_updt): + if (last_updt and node_storage[-1] and last_updt != node_storage[-1]) or ( + node_storage[-1] and not last_updt + ): self._sources_received.add(se) - logging.info(f"Update already received from source: {se} | ({len(self._sources_received)}/{len(self._sources_expected)}) Updates received") + logging.info( + f"Update already received from source: {se} | ({len(self._sources_received)}/{len(self._sources_expected)}) Updates received" + ) except: - logging.error(f"ERROR: source expected: {se} | last_update None: {(True if not last_updt else False)}, last update storaged None: {(True if not node_storage[-1] else False)}") + logging.exception( + f"ERROR: source expected: {se} | last_update None: {(True if not last_updt else False)}, last update storaged None: {(True if not node_storage[-1] else False)}" + ) async def storage_update(self, updt_received_event: UpdateReceivedEvent): time_received = time.time() (model, weight, source, round, _) = await updt_received_event.get_event_data() - if source in self._sources_expected: updt = Update(model, weight, source, round, time_received) await self._updates_storage_lock.acquire_async() @@ -135,7 +140,7 @@ async def get_round_updates(self): logging.info(f"Missing updates from sources: {updates_missing}") else: self._missing_ones.clear() - + self._nodes_using_historic.clear() updates = {} for sr in self._sources_received: @@ -212,6 +217,7 @@ async def _all_updates_received(self): all_received = False if len(updates_left) == 0: logging.info("All updates have been received this round") - await self._round_updates_lock.release_async() + if await self._round_updates_lock.locked_async(): + await self._round_updates_lock.release_async() all_received = True return all_received diff --git a/nebula/core/datasets/cifar10/cifar10.py b/nebula/core/datasets/cifar10/cifar10.py index 57dd4115c..9ad79cfda 100755 --- a/nebula/core/datasets/cifar10/cifar10.py +++ b/nebula/core/datasets/cifar10/cifar10.py @@ -86,21 +86,23 @@ def load_cifar10_dataset(self, train=True): download=True, ) - def generate_non_iid_map(self, dataset, partition="dirichlet", partition_parameter=0.5): + def generate_non_iid_map(self, dataset, partition="dirichlet", partition_parameter=0.5, num_clients=None): if partition == "dirichlet": - partitions_map = self.dirichlet_partition(dataset, alpha=partition_parameter) + partitions_map = self.dirichlet_partition(dataset, alpha=partition_parameter, n_clients=num_clients) elif partition == "percent": - partitions_map = self.percentage_partition(dataset, percentage=partition_parameter) + partitions_map = self.percentage_partition(dataset, percentage=partition_parameter, n_clients=num_clients) else: raise ValueError(f"Partition {partition} is not supported for Non-IID map") return partitions_map - def generate_iid_map(self, dataset, partition="balancediid", partition_parameter=2): + def generate_iid_map(self, dataset, partition="balancediid", partition_parameter=2, num_clients=None): if partition == "balancediid": - partitions_map = self.balanced_iid_partition(dataset) + partitions_map = self.balanced_iid_partition(dataset, n_clients=num_clients) elif partition == "unbalancediid": - partitions_map = self.unbalanced_iid_partition(dataset, imbalance_factor=partition_parameter) + partitions_map = self.unbalanced_iid_partition( + dataset, imbalance_factor=partition_parameter, n_clients=num_clients + ) else: raise ValueError(f"Partition {partition} is not supported for IID map") diff --git a/nebula/core/datasets/mnist/mnist.py b/nebula/core/datasets/mnist/mnist.py index d12232421..0a7ecde40 100755 --- a/nebula/core/datasets/mnist/mnist.py +++ b/nebula/core/datasets/mnist/mnist.py @@ -81,21 +81,23 @@ def load_mnist_dataset(self, train=True): download=True, ) - def generate_non_iid_map(self, dataset, partition="dirichlet", partition_parameter=0.5): + def generate_non_iid_map(self, dataset, partition="dirichlet", partition_parameter=0.5, num_clients=None): if partition == "dirichlet": - partitions_map = self.dirichlet_partition(dataset, alpha=partition_parameter) + partitions_map = self.dirichlet_partition(dataset, alpha=partition_parameter, n_clients=num_clients) elif partition == "percent": - partitions_map = self.percentage_partition(dataset, percentage=partition_parameter) + partitions_map = self.percentage_partition(dataset, percentage=partition_parameter, n_clients=num_clients) else: raise ValueError(f"Partition {partition} is not supported for Non-IID map") return partitions_map - def generate_iid_map(self, dataset, partition="balancediid", partition_parameter=2): + def generate_iid_map(self, dataset, partition="balancediid", partition_parameter=2, num_clients=None): if partition == "balancediid": - partitions_map = self.balanced_iid_partition(dataset) + partitions_map = self.balanced_iid_partition(dataset, n_clients=num_clients) elif partition == "unbalancediid": - partitions_map = self.unbalanced_iid_partition(dataset, imbalance_factor=partition_parameter) + partitions_map = self.unbalanced_iid_partition( + dataset, imbalance_factor=partition_parameter, n_clients=num_clients + ) else: raise ValueError(f"Partition {partition} is not supported for IID map") diff --git a/nebula/core/datasets/nebuladataset.py b/nebula/core/datasets/nebuladataset.py index ee4a8f085..970209e0f 100755 --- a/nebula/core/datasets/nebuladataset.py +++ b/nebula/core/datasets/nebuladataset.py @@ -1,7 +1,8 @@ -import math +import copy import os -from abc import ABC, abstractmethod import pickle +from abc import ABC, abstractmethod +from types import SimpleNamespace from typing import Any import h5py @@ -10,7 +11,7 @@ import numpy as np import seaborn as sns from sklearn.manifold import TSNE -import torch +from sklearn.model_selection import train_test_split from torch.utils.data import Dataset matplotlib.use("Agg") @@ -301,9 +302,13 @@ def __init__( partitions_number=1, batch_size=32, num_workers=4, - iid=True, + iid=False, partition="dirichlet", partition_parameter=0.5, + nsplits_percentages=[1.0], + nsplits_iid=["Non-IID"], + npartitions=["dirichlet"], + npartitions_parameter=[0.1], seed=42, config_dir=None, ): @@ -316,6 +321,11 @@ def __init__( self.partition_parameter = partition_parameter self.seed = seed self.config_dir = config_dir + self._nsplits_percentages = nsplits_percentages + self._nsplits_iid = nsplits_iid + self._npartitions = npartitions + self._npartitions_parameter = npartitions_parameter + self._targets_reales = None logging.info( f"Dataset {self.__class__.__name__} initialized | Partitions: {self.partitions_number} | IID: {self.iid} | Partition: {self.partition} | Partition parameter: {self.partition_parameter}" @@ -361,11 +371,16 @@ def data_partitioning(self, plot=False): f"Partitioning data for {self.__class__.__name__} | Partitions: {self.partitions_number} | IID: {self.iid} | Partition: {self.partition} | Partition parameter: {self.partition_parameter}" ) - self.train_indices_map = ( - self.generate_iid_map(self.train_set) - if self.iid - else self.generate_non_iid_map(self.train_set, self.partition, self.partition_parameter) - ) + logging.info(f"Scenario with data distribution IID: {self.iid}") + if self.iid: + self.train_indices_map = self.generate_iid_map(self.train_set) + else: + self.train_indices_map = self.generate_non_iid_map( + self.train_set, partition=self.partition, partition_parameter=self.partition_parameter + ) + # else: + # self.train_indices_map = self.generate_hybrid_map() + self.test_indices_map = self.get_test_indices_map() self.local_test_indices_map = self.get_local_test_indices_map() @@ -499,12 +514,134 @@ def generate_non_iid_map(self, dataset, partition="dirichlet", plot=False): pass @abstractmethod - def generate_iid_map(self, dataset, plot=False): + def generate_iid_map(self, dataset, plot=False, num_clients=None): """ Create an iid map of the dataset. """ pass + def generate_hybrid_map(self): + index = 0 + data = [] + targets = [] + sample, target = self.train_set.__getitem__(index) + while sample != None and target != None: + data.append(sample) + targets.append(target) + index += 1 + try: + sample, target = self.train_set.__getitem__(index) + except Exception: + break + data = np.array(data) + targets = np.array(targets) + self._targets_reales = targets.copy() # TODO remove + logging.info(f"number of samples on dataset: {len(data)}, targets: {targets}") + + remaining_size = 1.0 + subsets = [] + subset_to_split, targets_to_split = copy.deepcopy(data), copy.deepcopy(targets) + + participants = [i for i in range(self.partitions_number)] + num_participants = len(participants) + grouped_participants = [] + start_idx = 0 + + or_indices = np.arange(len(data)) + + # Inicializar las estructuras que se dividirΓ‘n en cada iteraciΓ³n + subset_to_split, targets_to_split, indices_to_split = ( + copy.deepcopy(data), + copy.deepcopy(targets), + copy.deepcopy(or_indices), + ) + + for i, size in enumerate(self._nsplits_percentages[:-1]): # Last one doesn't require split + relative_size = size / remaining_size # TamaΓ±o relativo respecto al conjunto restante + logging.info(f"size: {size}, relative size: {relative_size}, remaining size: {remaining_size}") + + # Dividir manteniendo referencias originales + x_s1, x_s2, y_s1, y_s2, idx_s1, idx_s2 = train_test_split( + subset_to_split, + targets_to_split, + indices_to_split, + test_size=(1 - relative_size), + stratify=targets_to_split, + random_state=42, + ) + + # Guardar los datos y etiquetas originales asociados a los Γ­ndices seleccionados + original_X_s1, original_y_s1 = data[idx_s1], targets[idx_s1] + + logging.info(f"Subset {i + 1}: {len(original_X_s1)} samples") + + # Guardar subset con referencia a los datos originales + subsets.append((original_X_s1, original_y_s1, idx_s1)) + + num_in_group = round(size * num_participants) + grouped_participants.append(participants[start_idx : start_idx + num_in_group]) + + # Actualizar para la siguiente iteraciΓ³n + subset_to_split, targets_to_split, indices_to_split = data[idx_s2], targets[idx_s2], idx_s2 + remaining_size -= size + start_idx += num_in_group + + # Guardar el ΓΊltimo subset con sus Γ­ndices originales + original_X_s2, original_y_s2 = data[indices_to_split], targets[indices_to_split] + subsets.append((original_X_s2, original_y_s2, indices_to_split)) + grouped_participants.append(participants[start_idx:]) + + for i, (_, ysubset, _) in enumerate(subsets): + logging.info(f"Subset {i + 1} - {np.bincount(ysubset)}") + + general_map = {} + for i, subset in enumerate(subsets): + data_mapped = dict() + real_indexes = subset[2] + subset_real_data = data[real_indexes] + subset_real_targets = targets[real_indexes] + + dataset_wrapped = SimpleNamespace( + data=subset_real_data, targets=subset_real_targets, real_indexes=real_indexes + ) + + if self._nsplits_iid[i] == "IID": + logging.info( + f"Generating dataset subset IID for participants: {grouped_participants[i]}, num_clients: {len(grouped_participants[i])}" + ) + subset_map = self.generate_iid_map( + dataset_wrapped, + self._npartitions[i], + self._npartitions_parameter[i], + num_clients=len(grouped_participants[i]), + ) + for j, real_id in enumerate( + grouped_participants[i] + ): # Mapping subset map generated to real clients IDs + data_mapped[real_id] = subset_map[j] + + else: + logging.info( + f"Generating dataset subset Non-IID for participants: {grouped_participants[i]}, num_clients: {len(grouped_participants[i])}" + ) + subset_map = self.generate_non_iid_map( + dataset_wrapped, + self._npartitions[i], + self._npartitions_parameter[i], + num_clients=len(grouped_participants[i]), + ) + for j, real_id in enumerate( + grouped_participants[i] + ): # Mapping subset map generated to real clients IDs + data_mapped[real_id] = subset_map[j] + + general_map.update(data_mapped) + for id, indexes in general_map.items(): + logging.info( + f" Participant id: {id}, num samples: {len(indexes)}, targets: {np.bincount(targets[indexes])}" + ) + return general_map + def plot_data_distribution(self, phase, dataset, partitions_map): """ Plot the data distribution of the dataset. @@ -591,11 +728,12 @@ def visualize_tsne(self, dataset): def dirichlet_partition( self, dataset: Any, - alpha: float = 0.5, + alpha: float = 0.2, + n_clients=None, min_samples_size: int = 50, balanced: bool = False, max_iter: int = 100, - verbose: bool = True, + verbose: bool = False, ) -> dict[int, list[int]]: """ Partition the dataset among clients using a Dirichlet distribution. @@ -622,55 +760,84 @@ def dirichlet_partition( partitions : dict[int, list[int]] Dictionary mapping each client index to a list of sample indices. """ + num_clients = self.partitions_number if not n_clients else n_clients + logging.info(f"Generating Dirichlet Partitioning, alpha: {alpha}, num_clients: {num_clients}") + # Extract targets and unique labels. - y_data = self._get_targets(dataset) - unique_labels = np.unique(y_data) + if not n_clients: + y_data = self._get_targets(dataset) + unique_labels = np.unique(y_data) + else: + if verbose: + logging.info("Extracting dataset partition targets...") + # For hybrid dataset scenarios + y_data = dataset.targets + unique_labels = np.unique(y_data) + if verbose: + logging.info(f"Unique labels in dataset: {unique_labels}") # For each class, get a shuffled list of indices. class_indices = {} base_rng = np.random.default_rng(self.seed) for label in unique_labels: - idx = np.where(y_data == label)[0] + if not n_clients: + idx = np.where(y_data == label)[0] + else: + ri = dataset.real_indexes + idx = np.where(self._targets_reales[ri] == label)[0] + idx = ri[idx] + # logging.info(f"attempting: {self._targets_reales[idx]}") + base_rng.shuffle(idx) class_indices[label] = idx # Prepare container for client indices. - indices_per_partition = [[] for _ in range(self.partitions_number)] + indices_per_partition = [[] for _ in range(num_clients)] - def allocate_for_label(label_idx: np.ndarray, rng: np.random.Generator) -> np.ndarray: + def allocate_for_label(label_idx: np.ndarray, rng: np.random.Generator, n_clients) -> np.ndarray: num_label_samples = len(label_idx) + if verbose: + logging.info(f"number of samples allocating {num_label_samples}") if balanced: - proportions = np.full(self.partitions_number, 1.0 / self.partitions_number) + proportions = np.full(n_clients, 1.0 / n_clients) else: - proportions = rng.dirichlet([alpha] * self.partitions_number) + proportions = rng.dirichlet([alpha] * n_clients) sample_counts = (proportions * num_label_samples).astype(int) remainder = num_label_samples - sample_counts.sum() if remainder > 0: - extra_indices = rng.choice(self.partitions_number, size=remainder, replace=False) + extra_indices = rng.choice(n_clients, size=remainder, replace=False) for idx in extra_indices: sample_counts[idx] += 1 + if verbose: + logging.info(f"Samples allocated per client: {sample_counts}") return sample_counts for iteration in range(1, max_iter + 1): rng = np.random.default_rng(self.seed + iteration) - temp_indices_per_partition = [[] for _ in range(self.partitions_number)] + temp_indices_per_partition = [[] for _ in range(num_clients)] for label in unique_labels: label_idx = class_indices[label] - counts = allocate_for_label(label_idx, rng) + if verbose: + logging.info(f"Calculating samples distribution for label: {label}") + counts = allocate_for_label(label_idx, rng, num_clients) start = 0 for client_idx, count in enumerate(counts): end = start + count temp_indices_per_partition[client_idx].extend(label_idx[start:end]) + if verbose: + logging.info( + f"Counting check: {np.bincount(self._targets_reales[temp_indices_per_partition[client_idx]])}" + ) start = end client_sizes = [len(indices) for indices in temp_indices_per_partition] if min(client_sizes) >= min_samples_size: indices_per_partition = temp_indices_per_partition if verbose: - print(f"Partition successful at iteration {iteration}. Client sizes: {client_sizes}") + logging.info(f"Partition successful at iteration {iteration}. Client sizes: {client_sizes}") break if verbose: - print(f"Iteration {iteration}: client sizes {client_sizes}") + logging.info(f"Iteration {iteration}: client sizes {client_sizes}") else: raise ValueError( @@ -678,9 +845,7 @@ def allocate_for_label(label_idx: np.ndarray, rng: np.random.Generator) -> np.nd ) initial_partition = {i: indices for i, indices in enumerate(indices_per_partition)} - - final_partition = self.postprocess_partition(initial_partition, y_data) - + final_partition = initial_partition # self.postprocess_partition(initial_partition, y_data) return final_partition @staticmethod @@ -797,7 +962,7 @@ def homo_partition(self, dataset): return net_dataidx_map - def balanced_iid_partition(self, dataset): + def balanced_iid_partition(self, dataset, n_clients=None): """ Partition the dataset into balanced and IID (Independent and Identically Distributed) subsets for each client. @@ -823,7 +988,8 @@ def balanced_iid_partition(self, dataset): federated_data = balanced_iid_partition(my_dataset) # This creates federated data subsets with equal class distributions. """ - num_clients = self.partitions_number + logging.info("Generating balanced IID partition") + num_clients = self.partitions_number if not n_clients else n_clients clients_data = {i: [] for i in range(num_clients)} # Get the labels from the dataset @@ -839,8 +1005,13 @@ def balanced_iid_partition(self, dataset): min_count = label_counts[min_label] for label in range(self.num_classes): - # Get the indices of the same label samples - label_indices = np.where(labels == label)[0] + if not n_clients: + label_indices = np.where(labels == label)[0] + else: # For hybrid dataset scenarios + ri = dataset.real_indexes + label_indices = np.where(self._targets_reales[ri] == label)[0] + label_indices = ri[label_indices] + np.random.seed(self.seed) np.random.shuffle(label_indices) @@ -854,7 +1025,7 @@ def balanced_iid_partition(self, dataset): return clients_data - def unbalanced_iid_partition(self, dataset, imbalance_factor=2): + def unbalanced_iid_partition(self, dataset, imbalance_factor=2, n_clients=None): """ Partition the dataset into multiple IID (Independent and Identically Distributed) subsets with different size. @@ -885,12 +1056,18 @@ def unbalanced_iid_partition(self, dataset, imbalance_factor=2): # This creates federated data subsets with varying number of samples based on # an imbalance factor of 2. """ - num_clients = self.partitions_number + logging.info("Generating unbalanced IID partition") + num_clients = self.partitions_number if not n_clients else n_clients clients_data = {i: [] for i in range(num_clients)} # Get the labels from the dataset - labels = np.array([dataset.targets[idx] for idx in range(len(dataset))]) + if not n_clients: + labels = np.array([dataset.targets[idx] for idx in range(len(dataset))]) + else: + labels = np.array(self._targets_reales[dataset.real_indexes]) + label_counts = np.bincount(labels) + logging.info(f"label_counts: {label_counts}") min_label = label_counts.argmin() min_count = label_counts[min_label] @@ -905,7 +1082,13 @@ def unbalanced_iid_partition(self, dataset, imbalance_factor=2): for label in range(self.num_classes): # Get the indices of the same label samples - label_indices = np.where(labels == label)[0] + if not n_clients: + label_indices = np.where(labels == label)[0] + else: # For hybrid dataset scenarios + ri = dataset.real_indexes + label_indices = np.where(self._targets_reales[ri] == label)[0] + label_indices = ri[label_indices] + np.random.seed(self.seed) np.random.shuffle(label_indices) @@ -918,7 +1101,7 @@ def unbalanced_iid_partition(self, dataset, imbalance_factor=2): return clients_data - def percentage_partition(self, dataset, percentage=20): + def percentage_partition(self, dataset, percentage=20, n_clients=None): """ Partition a dataset into multiple subsets with a specified level of non-IID-ness. @@ -947,11 +1130,20 @@ def percentage_partition(self, dataset, percentage=20): y_train = np.asarray(dataset.targets) num_classes = self.num_classes - num_subsets = self.partitions_number - class_indices = {i: np.where(y_train == i)[0] for i in range(num_classes)} + num_subsets = self.partitions_number if not n_clients else n_clients + + if not n_clients: + class_indices = {i: np.where(y_train == i)[0] for i in range(num_classes)} + else: + # TODO adapt, bad right now + ri = dataset.real_indexes + class_indices = {i: "" for i in range(num_classes)} # Get the labels from the dataset - labels = np.array([dataset.targets[idx] for idx in range(len(dataset))]) + if not n_clients: + labels = np.array([dataset.targets[idx] for idx in range(len(dataset))]) + else: + labels = np.array(self._targets_reales[dataset.real_indexes]) label_counts = np.bincount(labels) min_label = label_counts.argmin() @@ -1084,3 +1276,24 @@ def plot_all_data_distribution(self, phase, dataset, partitions_map): path_to_save = f"{self.config_dir}/all_data_distribution_CIRCLES_{'iid' if self.iid else 'non_iid'}{'_' + self.partition if not self.iid else ''}_{phase}.pdf" plt.savefig(path_to_save, dpi=300, bbox_inches="tight") plt.close() + + +def factory_nebuladataset(dataset, **config) -> NebulaDataset: + from nebula.core.datasets.cifar10.cifar10 import CIFAR10Dataset + from nebula.core.datasets.cifar100.cifar100 import CIFAR100Dataset + from nebula.core.datasets.emnist.emnist import EMNISTDataset + from nebula.core.datasets.fashionmnist.fashionmnist import FashionMNISTDataset + from nebula.core.datasets.mnist.mnist import MNISTDataset + + options = { + "MNIST": MNISTDataset, + "FashionMNIST": FashionMNISTDataset, + "EMNIST": EMNISTDataset, + "CIFAR10": CIFAR10Dataset, + "CIFAR100": CIFAR100Dataset, + } + + cs = options.get(dataset) + if not cs: + raise ValueError(f"Dataset {dataset} not supported") + return cs(**config) diff --git a/nebula/core/engine.py b/nebula/core/engine.py index 95aa3c966..9828a83b9 100644 --- a/nebula/core/engine.py +++ b/nebula/core/engine.py @@ -2,18 +2,26 @@ import logging import os import time + import docker from nebula.addons.attacks.attacks import create_attack from nebula.addons.functions import print_msg_box from nebula.addons.reporter import Reporter -from nebula.core.addonmanager import AddonManager +from nebula.addons.reputation.reputation import Reputation +from nebula.core.addonmanager import AddondManager from nebula.core.aggregation.aggregator import create_aggregator from nebula.core.eventmanager import EventManager -from nebula.core.nebulaevents import AggregationEvent, RoundStartEvent, UpdateNeighborEvent, UpdateReceivedEvent +from nebula.core.nebulaevents import ( + AggregationEvent, + RoundEndEvent, + RoundStartEvent, + UpdateNeighborEvent, + UpdateReceivedEvent, +) from nebula.core.network.communications import CommunicationsManager +from nebula.core.situationalawareness.situationalawareness import SituationalAwareness from nebula.core.utils.locker import Locker -from nebula.addons.reputation.reputation import Reputation logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) @@ -54,6 +62,8 @@ def print_banner(): β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• A Platform for Decentralized Federated Learning Created by Enrique TomΓ‘s MartΓ­nez BeltrΓ‘n + Featured by Alejandro AvilΓ©s Serrano + Featured by Fernando Torres Vega https://github.com/CyberDataLab/nebula """ logging.info(f"\n{banner}\n") @@ -92,6 +102,7 @@ def __init__( self.round = None self.total_rounds = None self.federation_nodes = set() + self._federation_nodes_lock = Locker("federation_nodes_lock", async_lock=True) self.initialized = False self.log_dir = os.path.join(config.participant["tracking_args"]["log_dir"], self.experiment_name) @@ -123,11 +134,23 @@ def __init__( self.config.reload_config_file() self._cm = CommunicationsManager(engine=self) - # Set the communication manager in the model (send messages from there) - self.trainer.model.set_communication_manager(self._cm) - self._reporter = Reporter(config=self.config, trainer=self.trainer, cm=self.cm) - self._addon_manager = AddonManager(self, self.config) - self._reputation = Reputation(self, self.config) + + self._reporter = Reporter(config=self.config, trainer=self.trainer) + + self._sinchronized_status = True + self.sinchronized_status_lock = Locker(name="sinchronized_status_lock") + + self.trainning_in_progress_lock = Locker(name="trainning_in_progress_lock", async_lock=True) + + event_manager = EventManager.get_instance(verbose=False) + self._addon_manager = AddondManager(self, self.config) + + # Additional Components + if "situational_awareness" in self.config.participant: + self._situational_awareness = SituationalAwareness(self.config, self) + + if self.config.participant["defense_args"]["with_reputation"]: + self._reputation = Reputation(engine=self, config=self.config) @property def cm(self): @@ -141,21 +164,30 @@ def reporter(self): def aggregator(self): return self._aggregator - def get_aggregator_type(self): - return type(self.aggregator) - @property def trainer(self): return self._trainer + @property + def sa(self): + return self._situational_awareness + + def get_aggregator_type(self): + return type(self.aggregator) + def get_addr(self): return self.addr def get_config(self): return self.config - def get_federation_nodes(self): - return self.federation_nodes + async def get_federation_nodes(self): + async with self._federation_nodes_lock: + return self.federation_nodes.copy() + + async def update_federation_nodes(self, federation_nodes): + async with self._federation_nodes_lock: + self.federation_nodes = federation_nodes def get_initialization_status(self): return self.initialized @@ -172,6 +204,9 @@ def get_federation_ready_lock(self): def get_federation_setup_lock(self): return self.federation_setup_lock + def get_trainning_in_progress_lock(self): + return self.trainning_in_progress_lock + def get_round_lock(self): return self.round_lock @@ -180,10 +215,9 @@ def set_round(self, new_round): self.round = new_round self.trainer.set_current_round(new_round) - """ - ############################## - # MODEL CALLBACKS # - ############################## + """ ############################## + # MODEL CALLBACKS # + ############################## """ async def model_initialization_callback(self, source, message): @@ -207,17 +241,16 @@ async def model_initialization_callback(self, source, message): async def model_update_callback(self, source, message): logging.info(f"πŸ€– handle_model_message | Received model update from {source} with round {message.round}") - if not self.get_federation_ready_lock().locked() and len(self.get_federation_nodes()) == 0: + if not self.get_federation_ready_lock().locked() and len(await self.get_federation_nodes()) == 0: logging.info("πŸ€– handle_model_message | There are no defined federation nodes") return decoded_model = self.trainer.deserialize_model(message.parameters) updt_received_event = UpdateReceivedEvent(decoded_model, message.weight, source, message.round) await EventManager.get_instance().publish_node_event(updt_received_event) - """ - ############################## - # General callbacks # - ############################## + """ ############################## + # General callbacks # + ############################## """ async def _discovery_discover_callback(self, source, message): @@ -263,11 +296,6 @@ async def _connection_connect_callback(self, source, message): async def _connection_disconnect_callback(self, source, message): logging.info(f"πŸ”— handle_connection_message | Trigger | Received disconnection message from {source}") - if self.mobility: - if await self.nm.waiting_confirmation_from(source): - await self.nm.confirmation_received(source, confirmation=False) - # if source in await self.cm.get_all_addrs_current_connections(only_direct=True): - await self.nm.update_neighbors(source, remove=True) await self.cm.disconnect(source, mutual_disconnection=False) async def _federation_federation_ready_callback(self, source, message): @@ -280,26 +308,6 @@ async def _federation_federation_start_callback(self, source, message): logging.info(f"πŸ“ handle_federation_message | Trigger | Received start federation message from {source}") await self.create_trainer_module() - async def _reputation_share_callback(self, source, message): - try: - logging.info(f"handle_reputation_message | Trigger | Received reputation message from {source} | Node: {message.node_id} | Score: {message.score} | Round: {message.round}") - - current_node = self.addr - nei = message.node_id - - # Manage reputation - if current_node != nei: - key = (current_node, nei, message.round) - - if key not in self._reputation.reputation_with_all_feedback: - self._reputation.reputation_with_all_feedback[key] = [] - - self._reputation.reputation_with_all_feedback[key].append(message.score) - #logging.info(f"Reputation with all feedback: {self.reputation_with_all_feedback}") - - except Exception as e: - logging.exception(f"Error handling reputation message: {e}") - async def _federation_federation_models_included_callback(self, source, message): logging.info(f"πŸ“ handle_federation_message | Trigger | Received aggregation finished message from {source}") try: @@ -320,10 +328,9 @@ async def _federation_federation_models_included_callback(self, source, message) finally: await self.cm.get_connections_lock().release_async() - """ - ############################## - # REGISTERING CALLBACKS # - ############################## + """ ############################## + # REGISTERING CALLBACKS # + ############################## """ async def register_events_callbacks(self): @@ -344,10 +351,8 @@ async def register_message_events_callbacks(self): for (message_name, message_actions) in me_dict.items() for message_action in message_actions ] - # logging.info(f"{message_events}") for event_type, action in message_events: callback_name = f"_{event_type}_{action}_callback" - # logging.info(f"Searching callback named: {callback_name}") method = getattr(self, callback_name, None) if callable(method): @@ -359,51 +364,101 @@ async def register_message_callback(self, message_event: tuple[str, str], callba if callable(method): await EventManager.get_instance().subscribe((event_type, action), method) + """ ############################## + # ENGINE FUNCTIONALITY # + ############################## """ - ############################## - # ENGINE FUNCTIONALITY # - ############################## - """ + + async def _aditional_node_start(self): + logging.info(f"Aditional node | {self.addr} | going to stablish connection with federation") + await self.sa.start_late_connection_process() + # continue .. + logging.info("Creating trainer service to start the federation process..") + asyncio.create_task(self._start_learning_late()) async def update_neighbors(self, removed_neighbor_addr, neighbors, remove=False): - self.federation_nodes = neighbors + await self.update_federation_nodes(neighbors) updt_nei_event = UpdateNeighborEvent(removed_neighbor_addr, remove) asyncio.create_task(EventManager.get_instance().publish_node_event(updt_nei_event)) - async def broadcast_models_include(self, aggregation_event: AggregationEvent): + async def broadcast_models_include(self, age: AggregationEvent): logging.info(f"πŸ”„ Broadcasting MODELS_INCLUDED for round {self.get_round()}") message = self.cm.create_message( "federation", "federation_models_included", [str(arg) for arg in [self.get_round()]] ) asyncio.create_task(self.cm.send_message_to_neighbors(message)) + async def update_model_learning_rate(self, new_lr): + await self.trainning_in_progress_lock.acquire_async() + logging.info("Update | learning rate modified...") + self.trainer.update_model_learning_rate(new_lr) + await self.trainning_in_progress_lock.release_async() + + async def _start_learning_late(self): + await self.learning_cycle_lock.acquire_async() + try: + model_serialized, rounds, round, _epochs = await self.sa.get_trainning_info() + self.total_rounds = rounds + epochs = _epochs + await self.get_round_lock().acquire_async() + self.round = round + await self.get_round_lock().release_async() + await self.learning_cycle_lock.release_async() + print_msg_box( + msg="Starting Federated Learning process...", + indent=2, + title="Start of the experiment late", + ) + logging.info(f"Trainning setup | total rounds: {rounds} | current round: {round} | epochs: {epochs}") + direct_connections = await self.cm.get_addrs_current_connections(only_direct=True) + logging.info(f"Initial DIRECT connections: {direct_connections}") + await asyncio.sleep(1) + try: + logging.info("πŸ€– Initializing model...") + await asyncio.sleep(1) + model = self.trainer.deserialize_model(model_serialized) + self.trainer.set_model_parameters(model, initialize=True) + logging.info("Model Parameters Initialized") + self.set_initialization_status(True) + await ( + self.get_federation_ready_lock().release_async() + ) # Enable learning cycle once the initialization is done + try: + await ( + self.get_federation_ready_lock().release_async() + ) # Release the lock acquired at the beginning of the engine + except RuntimeError: + pass + except RuntimeError: + pass + + self.trainer.set_epochs(epochs) + self.trainer.set_current_round(round) + self.trainer.create_trainer() + await self._learning_cycle() + + finally: + if await self.learning_cycle_lock.locked_async(): + await self.learning_cycle_lock.release_async() + async def create_trainer_module(self): asyncio.create_task(self._start_learning()) logging.info("Started trainer module...") async def start_communications(self): await self.register_events_callbacks() - await self.aggregator.init() - logging.info(f"Neighbors: {self.config.participant['network_args']['neighbors']}") - logging.info( - f"πŸ’€ Cold start time: {self.config.participant['misc_args']['grace_time_connection']} seconds before connecting to the network" - ) - await asyncio.sleep(self.config.participant["misc_args"]["grace_time_connection"]) - await self.cm.start() initial_neighbors = self.config.participant["network_args"]["neighbors"].split() - for i in initial_neighbors: - addr = f"{i.split(':')[0]}:{i.split(':')[1]}" - await self.cm.connect(addr, direct=True) - await asyncio.sleep(1) - while not self.cm.verify_connections(initial_neighbors): - await asyncio.sleep(1) - current_connections = await self.cm.get_addrs_current_connections() - logging.info(f"Connections verified: {current_connections}") + await self.cm.start_communications(initial_neighbors) + await asyncio.sleep(self.config.participant["misc_args"]["grace_time_connection"] // 2) + + async def deploy_components(self): + await self.aggregator.init() + if "situational_awareness" in self.config.participant: + await self.sa.init() + if self.config.participant["defense_args"]["with_reputation"]: + await self._reputation.setup() await self._reporter.start() - await self.cm.deploy_additional_services() await self._addon_manager.deploy_additional_services() - await self._reputation.setup() - await asyncio.sleep(self.config.participant["misc_args"]["grace_time_connection"] // 2) async def deploy_federation(self): await self.federation_ready_lock.acquire_async() @@ -470,50 +525,6 @@ async def _start_learning(self): if await self.learning_cycle_lock.locked_async(): await self.learning_cycle_lock.release_async() - async def _disrupt_connection_using_reputation(self, malicious_nodes): - malicious_nodes = list(set(malicious_nodes) & set(self.get_current_connections())) - logging.info(f"Disrupting connection with malicious nodes at round {self.round}") - logging.info(f"Removing {malicious_nodes} from {self.get_current_connections()}") - logging.info(f"Current connections before aggregation at round {self.round}: {self.get_current_connections()}") - for malicious_node in malicious_nodes: - if (self.get_name() != malicious_node) and (malicious_node not in self._secure_neighbors): - await self.cm.disconnect(malicious_node) - logging.info(f"Current connections after aggregation at round {self.round}: {self.get_current_connections()}") - - await self._connect_with_benign(malicious_nodes) - - async def _connect_with_benign(self, malicious_nodes): - lower_threshold = 1 - higher_threshold = len(self.federation_nodes) - 1 - if higher_threshold < lower_threshold: - higher_threshold = lower_threshold - - benign_nodes = [i for i in self.federation_nodes if i not in malicious_nodes] - logging.info(f"_reputation_callback benign_nodes at round {self.round}: {benign_nodes}") - if len(self.get_current_connections()) <= lower_threshold: - for node in benign_nodes: - if len(self.get_current_connections()) <= higher_threshold and self.get_name() != node: - connected = await self.cm.connect(node) - if connected: - logging.info(f"Connect new connection with at round {self.round}: {connected}") - - async def _dynamic_aggregator(self, aggregated_models_weights, malicious_nodes): - logging.info(f"malicious detected at round {self.round}, change aggergation protocol!") - if self.aggregator != self.target_aggregation: - logging.info(f"Current aggregator is: {self.aggregator}") - self.aggregator = self.target_aggregation - await self.aggregator.update_federation_nodes(self.federation_nodes) - - for subnodes in aggregated_models_weights: - sublist = subnodes.split() - (submodel, weights) = aggregated_models_weights[subnodes] - for node in sublist: - if node not in malicious_nodes: - await self.aggregator.include_model_in_buffer( - submodel, weights, source=self.get_name(), round=self.round - ) - logging.info(f"Current aggregator is: {self.aggregator}") - async def _waiting_model_updates(self): logging.info(f"πŸ’€ Waiting convergence in round {self.round}.") params = await self.aggregator.get_aggregation() @@ -525,6 +536,13 @@ async def _waiting_model_updates(self): else: logging.error("Aggregation finished with no parameters") + def print_round_information(self): + print_msg_box( + msg=f"Round {self.round} of {self.total_rounds} started.", + indent=2, + title="Round information", + ) + def learning_cycle_finished(self): return not (self.round < self.total_rounds) @@ -537,11 +555,13 @@ async def _learning_cycle(self): title="Round information", ) logging.info(f"Federation nodes: {self.federation_nodes}") - self.federation_nodes = await self.cm.get_addrs_current_connections(only_direct=True, myself=True) - expected_nodes = self.federation_nodes.copy() + await self.update_federation_nodes( + await self.cm.get_addrs_current_connections(only_direct=True, myself=True) + ) + expected_nodes = await self.get_federation_nodes() rse = RoundStartEvent(self.round, current_time, expected_nodes) await EventManager.get_instance().publish_node_event(rse) - self.trainer.on_round_start() + self.trainer.on_round_start() logging.info(f"Expected nodes: {expected_nodes}") direct_connections = await self.cm.get_addrs_current_connections(only_direct=True) undirected_connections = await self.cm.get_addrs_current_connections(only_undirected=True) @@ -549,8 +569,13 @@ async def _learning_cycle(self): logging.info(f"[Role {self.role}] Starting learning cycle...") await self.aggregator.update_federation_nodes(expected_nodes) await self._extended_learning_cycle() + + current_time = time.time() + ree = RoundEndEvent(self.round, current_time) + await EventManager.get_instance().publish_node_event(ree) + await self.get_round_lock().acquire_async() - + print_msg_box( msg=f"Round {self.round} of {self.total_rounds - 1} finished (max. {self.total_rounds} rounds)", indent=2, @@ -600,6 +625,7 @@ async def _extended_learning_cycle(self): """ pass + class MaliciousNode(Engine): def __init__( self, @@ -654,7 +680,9 @@ def __init__( async def _extended_learning_cycle(self): # Define the functionality of the aggregator node await self.trainer.test() + await self.trainning_in_progress_lock.acquire_async() await self.trainer.train() + await self.trainning_in_progress_lock.release_async() self_update_event = UpdateReceivedEvent( self.trainer.get_model_parameters(), self.trainer.get_model_weight(), self.addr, self.round diff --git a/nebula/core/eventmanager.py b/nebula/core/eventmanager.py index 8c253bc46..334b26585 100755 --- a/nebula/core/eventmanager.py +++ b/nebula/core/eventmanager.py @@ -1,6 +1,7 @@ import asyncio import inspect import logging +from collections.abc import Callable from nebula.core.nebulaevents import AddonEvent, NodeEvent from nebula.core.network.messages import MessageEvent @@ -9,10 +10,10 @@ class EventManager: _instance = None - _lock = Locker("event_manager") # To avoid race conditions in multithreaded environments + _lock = Locker("event_manager") def __new__(cls, *args, **kwargs): - """Implementation of the Singleton pattern.""" + """ImplementaciΓ³n del patrΓ³n Singleton.""" with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) @@ -20,8 +21,8 @@ def __new__(cls, *args, **kwargs): return cls._instance def _initialize(self, verbose=False): - """Initializes the single instance (runs only once).""" - if hasattr(self, "_initialized"): # Prevents resetting + """Inicializa la instancia ΓΊnica (solo se ejecuta una vez).""" + if hasattr(self, "_initialized"): return self._subscribers: dict[tuple[str, str], list] = {} self._message_events_lock = Locker("message_events_lock", async_lock=True) @@ -29,18 +30,26 @@ def _initialize(self, verbose=False): self._addons_event_lock = Locker("addons_event_lock", async_lock=True) self._node_events_subs: dict[type, list] = {} self._node_events_lock = Locker("node_events_lock", async_lock=True) + self._global_message_subscribers: list[Callable] = [] + self._global_message_subscribers_lock = Locker("global_message_subscribers_lock", async_lock=True) self._verbose = verbose - self._initialized = True # Mark already initialized + self._initialized = True @staticmethod def get_instance(verbose=False): - """Static method to get the unique instance.""" + """MΓ©todo estΓ‘tico para obtener la instancia ΓΊnica.""" if EventManager._instance is None: EventManager(verbose=verbose) return EventManager._instance - async def subscribe(self, event_type: tuple[str, str], callback: callable): - """Register a callback for a specific event type.""" + async def subscribe(self, event_type: tuple[str, str] | None, callback: Callable): + """Register a callback for a message event.""" + if not event_type: + async with self._global_message_subscribers_lock: + self._global_message_subscribers.append(callback) + logging.info(f"EventManager | Subscribed callback for all message events: {event_type}") + return + async with self._message_events_lock: if event_type not in self._subscribers: self._subscribers[event_type] = [] @@ -71,7 +80,24 @@ async def publish(self, message_event: MessageEvent): except Exception as e: logging.exception(f"EventManager | Error in callback for event {event_type}: {e}") - async def subscribe_addonevent(self, addonEventType: type[AddonEvent], callback: callable): + # Global callbacks (callbacks for all message events) + async with self._global_message_subscribers_lock: + global_callbacks = self._global_message_subscribers.copy() + + for global_cb in global_callbacks: + try: + if self._verbose: + logging.info( + f"EventManager | Triggering callback for event: {event_type}, from source: {message_event.source}" + ) + if asyncio.iscoroutinefunction(callback) or inspect.iscoroutine(callback): + await global_cb(message_event.source, message_event.message) + else: + global_cb(message_event.source, message_event.message) + except Exception as e: + logging.exception(f"EventManager | Error in callback for event {event_type}: {e}") + + async def subscribe_addonevent(self, addonEventType: type[AddonEvent], callback: Callable): """Register a callback for a specific type of AddonEvent.""" async with self._addons_event_lock: if addonEventType not in self._addons_events_subs: @@ -88,7 +114,8 @@ async def publish_addonevent(self, addonevent: AddonEvent): callbacks = self._addons_events_subs.get(event_type, []) if not callbacks: - logging.error(f"EventManager | No subscribers for AddonEvent type: {event_type.__name__}") + if self._verbose: + logging.error(f"EventManager | No subscribers for AddonEvent type: {event_type.__name__}") return for callback in self._addons_events_subs[event_type]: @@ -102,7 +129,7 @@ async def publish_addonevent(self, addonevent: AddonEvent): except Exception as e: logging.exception(f"EventManager | Error in callback for AddonEvent {event_type.__name__}: {e}") - async def subscribe_node_event(self, nodeEventType: type[NodeEvent], callback: callable): + async def subscribe_node_event(self, nodeEventType: type[NodeEvent], callback: Callable): """Register a callback for a specific type of AddonEvent.""" async with self._node_events_lock: if nodeEventType not in self._node_events_subs: diff --git a/nebula/core/nebulaevents.py b/nebula/core/nebulaevents.py index 67d15f60a..45e41f2f8 100644 --- a/nebula/core/nebulaevents.py +++ b/nebula/core/nebulaevents.py @@ -24,10 +24,9 @@ def __init__(self, message_type, source, message): self.message = message -""" -############################## -# NODE EVENTS # -############################## +""" ############################## + # NODE EVENTS # + ############################## """ @@ -38,7 +37,6 @@ def __init__(self, round, start_time, expected_nodes): Args: round (int): Round number. start_time (time): Current time when round is going to start. - rejected_nodes (set): Set of nodes that were rejected in the previous round. """ self._round_start_time = start_time self._round = round @@ -48,12 +46,62 @@ def __str__(self): return "Round starting" async def get_event_data(self): + """Retrieves the round start event data. + + Returns: + tuple[int, float]: + -round (int): Round number. + -start_time (time): Current time when round is going to start. + """ return (self._round, self._round_start_time, self._expected_nodes) async def is_concurrent(self): return False +class RoundEndEvent(NodeEvent): + def __init__(self, round, end_time): + """Event triggered when round is going to start. + + Args: + round (int): Round number. + end_time (time): Current time when round has ended. + """ + self._round_end_time = end_time + self._round = round + + def __str__(self): + return "Round ending" + + async def get_event_data(self): + """Retrieves the round start event data. + + Returns: + tuple[int, float]: + -round (int): Round number. + -end_time (time): Current time when round has ended. + """ + return (self._round, self._round_end_time) + + async def is_concurrent(self): + return False + + +class ExperimentFinishEvent(NodeEvent): + def __init__(self): + """Event triggered when experiment is going to finish.""" + pass + + def __str__(self): + return "Experiment finished" + + async def get_event_data(self): + pass + + async def is_concurrent(self): + return False + + class AggregationEvent(NodeEvent): def __init__(self, updates: dict, expected_nodes: set, missing_nodes: set): """Event triggered when model aggregation is ready. @@ -70,6 +118,10 @@ def __init__(self, updates: dict, expected_nodes: set, missing_nodes: set): def __str__(self): return "Aggregation Ready" + def update_updates(self, new_updates: dict): + """Allows an external module to update the updates dictionary.""" + self._updates = new_updates + async def get_event_data(self) -> tuple[dict, set, set]: """Retrieves the aggregation event data. @@ -86,7 +138,7 @@ async def is_concurrent(self) -> bool: class UpdateNeighborEvent(NodeEvent): - def __init__(self, node_addr, removed=False): + def __init__(self, node_addr, removed=False, joining=False): """Event triggered when a neighboring node is updated. Args: @@ -96,6 +148,7 @@ def __init__(self, node_addr, removed=False): """ self._node_addr = node_addr self._removed = removed + self._joining_federation = joining def __str__(self): return f"Node addr: {self._node_addr}, removed: {self._removed}" @@ -113,6 +166,49 @@ async def get_event_data(self) -> tuple[str, bool]: async def is_concurrent(self) -> bool: return False + def is_joining_federation(self): + return self._joining_federation + + +class NodeBlacklistedEvent(NodeEvent): + def __init__(self, node_addr, blacklisted: bool = False): + self._node_addr = node_addr + self._blacklisted = blacklisted + + def __str__(self): + return f"Node addr: {self._node_addr} | Blacklisted: {self._blacklisted} | Recently disconnected: {not self._blacklisted}" + + async def get_event_data(self) -> tuple[str, bool]: + return (self._node_addr, self._blacklisted) + + async def is_concurrent(self): + return True + + +class NodeFoundEvent(NodeEvent): + def __init__(self, node_addr): + """Event triggered when a new node is found. + + Args: + node_addr (str): Address of the neighboring node. + """ + self._node_addr = node_addr + + def __str__(self): + return f"Node addr: {self._node_addr} found" + + async def get_event_data(self) -> tuple[str, bool]: + """Retrieves the node found event data. + + Returns: + tuple[str, bool]: + - node_addr (str): Address of the node found. + """ + return self._node_addr + + async def is_concurrent(self) -> bool: + return True + class UpdateReceivedEvent(NodeEvent): def __init__(self, decoded_model, weight, source, round, local=False): @@ -135,7 +231,7 @@ def __init__(self, decoded_model, weight, source, round, local=False): def __str__(self): return f"Update received from source: {self._source}, round: {self._round}" - async def get_event_data(self) -> tuple[str, bool]: + async def get_event_data(self) -> tuple[object, int, str, int, bool]: """ Retrieves the event data. @@ -153,10 +249,39 @@ async def is_concurrent(self) -> bool: return False -""" -############################## -# ADDON EVENTS # -############################## +class BeaconRecievedEvent(NodeEvent): + def __init__(self, source, geoloc): + """ + Initializes an BeaconRecievedEvent. + + Args: + source (str): The received beacon source. + geoloc (tuple): The geolocalzition associated with the received beacon source. + """ + self._source = source + self._geoloc = geoloc + + def __str__(self): + return "Beacon recieved" + + async def get_event_data(self) -> tuple[str, tuple[float, float]]: + """ + Retrieves the event data. + + Returns: + tuple[str, tuple[float, float]]: A tuple containing: + - The beacon's source. + - the device geolocalization (latitude, longitude). + """ + return (self._source, self._geoloc) + + async def is_concurrent(self) -> bool: + return True + + +""" ############################## + # ADDON EVENTS # + ############################## """ @@ -169,3 +294,15 @@ def __str__(self): async def get_event_data(self) -> dict: return self.distances.copy() + + +class ChangeLocationEvent(AddonEvent): + def __init__(self, latitude, longitude): + self.latitude = latitude + self.longitude = longitude + + def __str__(self): + return "ChangeLocationEvent" + + async def get_event_data(self): + return (self.latitude, self.longitude) diff --git a/nebula/core/network/actions.py b/nebula/core/network/actions.py index 477f896e3..e1f18c5ea 100644 --- a/nebula/core/network/actions.py +++ b/nebula/core/network/actions.py @@ -1,76 +1,96 @@ -from enum import Enum - -from nebula.core.pb import nebula_pb2 - - -class ConnectionAction(Enum): - CONNECT = nebula_pb2.ConnectionMessage.Action.CONNECT - DISCONNECT = nebula_pb2.ConnectionMessage.Action.DISCONNECT - - -class FederationAction(Enum): - FEDERATION_START = nebula_pb2.FederationMessage.Action.FEDERATION_START - REPUTATION = nebula_pb2.FederationMessage.Action.REPUTATION - FEDERATION_MODELS_INCLUDED = nebula_pb2.FederationMessage.Action.FEDERATION_MODELS_INCLUDED - FEDERATION_READY = nebula_pb2.FederationMessage.Action.FEDERATION_READY - - -class DiscoveryAction(Enum): - DISCOVER = nebula_pb2.DiscoveryMessage.Action.DISCOVER - REGISTER = nebula_pb2.DiscoveryMessage.Action.REGISTER - DEREGISTER = nebula_pb2.DiscoveryMessage.Action.DEREGISTER - - -class ControlAction(Enum): - ALIVE = nebula_pb2.ControlMessage.Action.ALIVE - OVERHEAD = nebula_pb2.ControlMessage.Action.OVERHEAD - MOBILITY = nebula_pb2.ControlMessage.Action.MOBILITY - RECOVERY = nebula_pb2.ControlMessage.Action.RECOVERY - WEAK_LINK = nebula_pb2.ControlMessage.Action.WEAK_LINK - - -class ReputationAction(Enum): - SHARE = nebula_pb2.ReputationMessage.Action.SHARE - - -ACTION_CLASSES = { - "connection": ConnectionAction, - "federation": FederationAction, - "discovery": DiscoveryAction, - "control": ControlAction, - "reputation": ReputationAction, -} - - -def get_action_name_from_value(message_type: str, action_value: int) -> str: - # Get the Enum corresponding to the message type - enum_class = ACTION_CLASSES.get(message_type) - if not enum_class: - raise ValueError(f"Unknown message type: {message_type}") - - # Find the name of the action from the value - for action in enum_class: - if action.value == action_value: - return action.name.lower() # Convert to lowercase to maintain the format "late_connect" - - raise ValueError(f"Unknown action value {action_value} for message type {message_type}") - - -def get_actions_names(message_type: str): - message_actions = ACTION_CLASSES.get(message_type) - if not message_actions: - raise ValueError(f"Invalid message type: {message_type}") - - return [action.name.lower() for action in message_actions] - - -def factory_message_action(message_type: str, action: str): - message_actions = ACTION_CLASSES.get(message_type) - - if message_actions: - normalized_action = action.upper() - enum_action = message_actions[normalized_action] - # logging.info(f"Message action: {enum_action}, value: {enum_action.value}") - return enum_action.value - else: - return None +from enum import Enum + +from nebula.core.pb import nebula_pb2 + + +class ConnectionAction(Enum): + CONNECT = nebula_pb2.ConnectionMessage.Action.CONNECT + DISCONNECT = nebula_pb2.ConnectionMessage.Action.DISCONNECT + LATE_CONNECT = nebula_pb2.ConnectionMessage.Action.LATE_CONNECT + RESTRUCTURE = nebula_pb2.ConnectionMessage.Action.RESTRUCTURE + + +class FederationAction(Enum): + FEDERATION_START = nebula_pb2.FederationMessage.Action.FEDERATION_START + REPUTATION = nebula_pb2.FederationMessage.Action.REPUTATION + FEDERATION_MODELS_INCLUDED = nebula_pb2.FederationMessage.Action.FEDERATION_MODELS_INCLUDED + FEDERATION_READY = nebula_pb2.FederationMessage.Action.FEDERATION_READY + + +class DiscoveryAction(Enum): + DISCOVER = nebula_pb2.DiscoveryMessage.Action.DISCOVER + REGISTER = nebula_pb2.DiscoveryMessage.Action.REGISTER + DEREGISTER = nebula_pb2.DiscoveryMessage.Action.DEREGISTER + + +class ControlAction(Enum): + ALIVE = nebula_pb2.ControlMessage.Action.ALIVE + OVERHEAD = nebula_pb2.ControlMessage.Action.OVERHEAD + MOBILITY = nebula_pb2.ControlMessage.Action.MOBILITY + RECOVERY = nebula_pb2.ControlMessage.Action.RECOVERY + WEAK_LINK = nebula_pb2.ControlMessage.Action.WEAK_LINK + + +class DiscoverAction(Enum): + DISCOVER_JOIN = nebula_pb2.DiscoverMessage.Action.DISCOVER_JOIN + DISCOVER_NODES = nebula_pb2.DiscoverMessage.Action.DISCOVER_NODES + + +class OfferAction(Enum): + OFFER_MODEL = nebula_pb2.OfferMessage.Action.OFFER_MODEL + OFFER_METRIC = nebula_pb2.OfferMessage.Action.OFFER_METRIC + + +class LinkAction(Enum): + CONNECT_TO = nebula_pb2.LinkMessage.Action.CONNECT_TO + DISCONNECT_FROM = nebula_pb2.LinkMessage.Action.DISCONNECT_FROM + + +class ReputationAction(Enum): + SHARE = nebula_pb2.ReputationMessage.Action.SHARE + + +ACTION_CLASSES = { + "connection": ConnectionAction, + "federation": FederationAction, + "discovery": DiscoveryAction, + "control": ControlAction, + "discover": DiscoverAction, + "offer": OfferAction, + "link": LinkAction, + "reputation": ReputationAction, +} + + +def get_action_name_from_value(message_type: str, action_value: int) -> str: + # Get the Enum corresponding to the message type + enum_class = ACTION_CLASSES.get(message_type) + if not enum_class: + raise ValueError(f"Unknown message type: {message_type}") + + # Find the name of the action from the value + for action in enum_class: + if action.value == action_value: + return action.name.lower() # Convert to lowercase to maintain the format "late_connect" + + raise ValueError(f"Unknown action value {action_value} for message type {message_type}") + + +def get_actions_names(message_type: str): + message_actions = ACTION_CLASSES.get(message_type) + if not message_actions: + raise ValueError(f"Invalid message type: {message_type}") + + return [action.name.lower() for action in message_actions] + + +def factory_message_action(message_type: str, action: str): + message_actions = ACTION_CLASSES.get(message_type) + + if message_actions: + normalized_action = action.upper() + enum_action = message_actions[normalized_action] + # logging.info(f"Message action: {enum_action}, value: {enum_action.value}") + return enum_action.value + else: + return None diff --git a/nebula/core/network/blacklist.py b/nebula/core/network/blacklist.py new file mode 100644 index 000000000..cdddc87e3 --- /dev/null +++ b/nebula/core/network/blacklist.py @@ -0,0 +1,158 @@ +import asyncio +import logging +import time + +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import NodeBlacklistedEvent +from nebula.core.utils.locker import Locker + +BLACKLIST_EXPIRATION_TIME = 240 +RECENTLY_DISCONNECTED_EXPIRE_TIME = 60 + + +class BlackList: + def __init__(self, max_time_listed=BLACKLIST_EXPIRATION_TIME): + self._max_time_listed = max_time_listed + self._blacklisted_nodes: dict = {} + self._recently_disconnected: set = set() + self._recently_disconnected_lock = Locker(name="recently_disconnected_lock", async_lock=True) + self._blacklisted_nodes_lock = Locker(name="blacklisted_nodes_lock", async_lock=True) + self._bl_cleaner_running = False + self._blacklist_cleaner_wake_up = asyncio.Event() + self._running = False + + async def apply_restrictions(self, nodes) -> set | None: + nodes_allowed = await self.verify_allowed_nodes(nodes) + # logging.info(f"nodes allowed after appliying blacklist restricttions: {nodes_allowed}") + if nodes_allowed: + nodes_allowed = await self.verify_not_recently_disc(nodes_allowed) + # logging.info(f"nodes allowed after seen recently disconnection restrictions: {nodes_allowed}") + return nodes_allowed + + async def clear_restrictions(self): + await self.clear_blacklist() + await self.clear_recently_disconected() + + """ ############################## + # BLACKLIST # + ############################## + """ + + async def add_to_blacklist(self, addr): + logging.info(f"Update blackList | addr listed: {addr}") + await self._blacklisted_nodes_lock.acquire_async() + expiration_time = time.time() + self._blacklisted_nodes[addr] = expiration_time + if not self._running: + self._running = True + asyncio.create_task(self._start_blacklist_cleaner()) + await self._blacklisted_nodes_lock.release_async() + nbe = NodeBlacklistedEvent(addr, blacklisted=True) + asyncio.create_task(EventManager.get_instance().publish_node_event(nbe)) + + async def get_blacklist(self) -> set: + bl = None + await self._blacklisted_nodes_lock.acquire_async() + if self._blacklisted_nodes: + bl = set(self._blacklisted_nodes.keys()) + await self._blacklisted_nodes_lock.release_async() + return bl + + async def clear_blacklist(self): + await self._blacklisted_nodes_lock.acquire_async() + logging.info("🧹 Removing nodes from blacklist") + self._blacklisted_nodes.clear() + await self._blacklisted_nodes_lock.release_async() + + async def _start_blacklist_cleaner(self): + while self._running: + await self._blacklist_clean() + await self._blacklist_cleaner_wait() + + async def _blacklist_clean(self): + await self._blacklisted_nodes_lock.acquire_async() + logging.info("BlackList cleaner has waken up") + now = time.time() + new_bl = {} + + for addr, timer in self._blacklisted_nodes.items(): + if timer + self._max_time_listed >= now: + new_bl[addr] = timer + else: + logging.info(f"Removing addr{addr} from blacklisted nodes...") + + self._blacklisted_nodes = new_bl + if not new_bl: + self._running = False + await self._blacklisted_nodes_lock.release_async() + + async def _blacklist_cleaner_wait(self): + try: + await asyncio.sleep(self._max_time_listed) + except TimeoutError: + pass + + async def node_in_blacklist(self, addr): + blacklisted = False + await self._blacklisted_nodes_lock.acquire_async() + if self._blacklisted_nodes: + blacklisted = addr in self._blacklisted_nodes.keys() + await self._blacklisted_nodes_lock.release_async() + return blacklisted + + async def verify_allowed_nodes(self, nodes: set) -> set | None: + if not nodes: + return None + nodes_not_listed = nodes + await self._blacklisted_nodes_lock.acquire_async() + blacklist = self._blacklisted_nodes + if blacklist: + nodes_not_listed = nodes.difference(blacklist) + await self._blacklisted_nodes_lock.release_async() + return nodes_not_listed + + """ ############################## + # RECENTLY DISCONNECTED # + ############################## + """ + + async def add_recently_disconnected(self, addr): + logging.info(f"Recently disconnected from: {addr}") + self._recently_disconnected_lock.acquire_async() + self._recently_disconnected.add(addr) + self._recently_disconnected_lock.release_async() + asyncio.create_task(self._remove_recently_disc(addr)) + nbe = NodeBlacklistedEvent(addr) + asyncio.create_task(EventManager.get_instance().publish_node_event(nbe)) + + async def clear_recently_disconected(self): + self._recently_disconnected_lock.acquire_async() + logging.info("🧹 Removing nodes from Recently Disconencted list") + self._recently_disconnected.clear() + self._recently_disconnected_lock.release_async() + + async def get_recently_disconnected(self): + rd = None + self._recently_disconnected_lock.acquire_async() + rd = self._recently_disconnected.copy() + self._recently_disconnected_lock.release_async() + return rd + + async def _remove_recently_disc(self, addr): + await asyncio.sleep(RECENTLY_DISCONNECTED_EXPIRE_TIME) + self._recently_disconnected_lock.acquire_async() + self._recently_disconnected.discard(addr) + logging.info(f"Recently disconnection timeout expired for souce: {addr}") + self._recently_disconnected_lock.release_async() + + async def verify_not_recently_disc(self, nodes: set) -> set | None: + if not nodes: + return None + nodes_not_listed = nodes + self._recently_disconnected_lock.acquire_async() + rec_disc = self._recently_disconnected + # logging.info(f"recently disconencted nodes: {rec_disc}") + if rec_disc: + nodes_not_listed = nodes.difference(rec_disc) + self._recently_disconnected_lock.release_async() + return nodes_not_listed diff --git a/nebula/core/network/communications.py b/nebula/core/network/communications.py index 4eb3a8ec9..ecc9fc9d1 100755 --- a/nebula/core/network/communications.py +++ b/nebula/core/network/communications.py @@ -1,15 +1,16 @@ import asyncio import collections import logging -import sys -import time +from typing import TYPE_CHECKING + import requests -from typing import TYPE_CHECKING from nebula.core.eventmanager import EventManager from nebula.core.nebulaevents import MessageEvent +from nebula.core.network.blacklist import BlackList from nebula.core.network.connection import Connection from nebula.core.network.discoverer import Discoverer +from nebula.core.network.externalconnection.externalconnectionservice import factory_connection_service from nebula.core.network.forwarder import Forwarder from nebula.core.network.messages import MessagesManager from nebula.core.network.propagator import Propagator @@ -18,9 +19,34 @@ if TYPE_CHECKING: from nebula.core.engine import Engine +BLACKLIST_EXPIRATION_TIME = 60 + +_COMPRESSED_MESSAGES = [ + "model", + "offer_model" +] class CommunicationsManager: + _instance = None + _lock = Locker("communications_manager_lock", async_lock=False) + + def __new__(cls, engine: "Engine"): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls): + """Obtain CommunicationsManager instance""" + if cls._instance is None: + raise ValueError("CommunicationsManager has not been initialized yet.") + return cls._instance + def __init__(self, engine: "Engine"): + if hasattr(self, "_initialized") and self._initialized: + return # Avoid reinicialization + logging.info("🌐 Initializing Communications Manager") self._engine = engine self.addr = engine.get_addr() @@ -43,16 +69,16 @@ def __init__(self, engine: "Engine"): self.outgoing_connections = {} self.ready_connections = set() - self._mm = MessagesManager(addr=self.addr, config=self.config, cm=self) + self._mm = MessagesManager(addr=self.addr, config=self.config) self.received_messages_hashes = collections.deque( maxlen=self.config.participant["message_args"]["max_local_messages"] ) self.receive_messages_lock = Locker(name="receive_messages_lock", async_lock=True) - self._discoverer = Discoverer(addr=self.addr, config=self.config, cm=self) - # self._health = Health(addr=self.addr, config=self.config, cm=self) - self._forwarder = Forwarder(config=self.config, cm=self) - self._propagator = Propagator(cm=self) + self._discoverer = Discoverer(addr=self.addr, config=self.config) + # self._health = Health(addr=self.addr, config=self.config) + self._forwarder = Forwarder(config=self.config) + self._propagator = Propagator() # List of connections to reconnect {addr: addr, tries: 0} self.connections_reconnect = [] @@ -62,7 +88,15 @@ def __init__(self, engine: "Engine"): self.stop_network_engine = asyncio.Event() self.loop = asyncio.get_event_loop() max_concurrent_tasks = 5 - self.semaphore_send_model = asyncio.Semaphore(max_concurrent_tasks) + self.semaphore_send_model = asyncio.Semaphore(max_concurrent_tasks) + + self._blacklist = BlackList() + + # Connection service to communicate with external devices + self._external_connection_service = factory_connection_service("nebula", self.addr) + + self._initialized = True + logging.info("Communication Manager initialization completed") @property def engine(self): @@ -92,6 +126,14 @@ def forwarder(self): def propagator(self): return self._propagator + @property + def ecs(self): + return self._external_connection_service + + @property + def bl(self): + return self._blacklist + async def check_federation_ready(self): # Check if all my connections are in ready_connections logging.info( @@ -103,22 +145,38 @@ async def check_federation_ready(self): async def add_ready_connection(self, addr): self.ready_connections.add(addr) - """ - ############################## - # PROCESSING MESSAGES # - ############################## + async def start_communications(self, initial_neighbors): + logging.info(f"Neighbors: {self.config.participant['network_args']['neighbors']}") + logging.info( + f"πŸ’€ Cold start time: {self.config.participant['misc_args']['grace_time_connection']} seconds before connecting to the network" + ) + await asyncio.sleep(self.config.participant["misc_args"]["grace_time_connection"]) + await self.start() + for i in initial_neighbors: + addr = f"{i.split(':')[0]}:{i.split(':')[1]}" + await self.connect(addr, direct=True) + await asyncio.sleep(1) + while not self.verify_connections(initial_neighbors): + await asyncio.sleep(1) + current_connections = await self.get_addrs_current_connections() + logging.info(f"Connections verified: {current_connections}") + await self.deploy_additional_services() + + """ ############################## + # PROCESSING MESSAGES # + ############################## """ async def handle_incoming_message(self, data, addr_from): - await self.mm.process_message(data, addr_from) + if not await self.bl.node_in_blacklist(addr_from): + await self.mm.process_message(data, addr_from) async def forward_message(self, data, addr_from): logging.info("Forwarding message... ") await self.forwarder.forward(data, addr_from=addr_from) async def handle_message(self, message_event): - #asyncio.create_task(EventManager.get_instance().publish(message_event)) - await EventManager.get_instance().publish(message_event) + asyncio.create_task(EventManager.get_instance().publish(message_event)) async def handle_model_message(self, source, message): logging.info(f"πŸ€– handle_model_message | Received model from {source} with round {message.round}") @@ -135,10 +193,104 @@ def create_message(self, message_type: str, action: str = "", *args, **kwargs): def get_messages_events(self): return self.mm.get_messages_events() + """ ############################## + # BLACKLIST # + ############################## """ - ############################## - # OTHER FUNCTIONALITIES # - ############################## + + async def add_to_recently_disconnected(self, addr): + await self.bl.add_recently_disconnected(addr) + + async def add_to_blacklist(self, addr): + await self.bl.add_to_blacklist(addr) + + async def get_blacklist(self): + return await self.bl.get_blacklist() + + async def apply_restrictions(self, nodes: set) -> set | None: + return await self.bl.apply_restrictions(nodes) + + async def clear_restrictions(self): + await self.bl.clear_restrictions() + + """ ############################### + # EXTERNAL CONNECTION SERVICE # + ############################### + """ + + async def start_external_connection_service(self, run_service=True): + if self.ecs == None: + self._external_connection_service = factory_connection_service(self, self.addr) + if run_service: + await self.ecs.start() + + async def stop_external_connection_service(self): + await self.ecs.stop() + + async def init_external_connection_service(self): + await self.start_external_connection_service() + + async def is_external_connection_service_running(self): + return self.ecs.is_running() + + async def start_beacon(self): + await self.ecs.start_beacon() + + async def stop_beacon(self): + await self.ecs.stop_beacon() + + async def modify_beacon_frequency(self, frequency): + await self.ecs.modify_beacon_frequency(frequency) + + async def stablish_connection_to_federation(self, msg_type="discover_join", addrs_known=None): + """ + Using ExternalConnectionService to get addrs on local network, after that + stablishment of TCP connection and send the message broadcasted + """ + addrs = [] + if addrs_known == None: + logging.info("Searching federation process beginning...") + addrs = await self.ecs.find_federation() + logging.info(f"Found federation devices | addrs {addrs}") + else: + logging.info(f"Searching federation process beginning... | Using addrs previously known {addrs_known}") + addrs = addrs_known + + msg = self.create_message("discover", msg_type) + + # Remove neighbors + neighbors = await self.get_addrs_current_connections(only_direct=True, myself=True) + addrs = set(addrs) + if neighbors: + addrs.difference_update(neighbors) + + # logging.info(f"neighbors: {neighbors} | addr filtered: {addrs}") + discovers_sent = 0 + connections_made = set() + if addrs: + logging.info("Starting communications with devices found") + max_tries = 5 + for addr in addrs: + await self.connect(addr, direct=False, priority="high") + connections_made.add(addr) + await asyncio.sleep(1) + for i in range(0, max_tries): + if self.verify_any_connections(addrs): + break + await asyncio.sleep(1) + current_connections = await self.get_addrs_current_connections(only_undirected=True) + logging.info(f"Connections verified after searching: {current_connections}") + + for addr in addrs: + logging.info(f"Sending {msg_type} to addr: {addr}") + asyncio.create_task(self.send_message(addr, msg)) + await asyncio.sleep(1) + discovers_sent += 1 + return (discovers_sent, connections_made) + + """ ############################## + # OTHER FUNCTIONALITIES # + ############################## """ def get_connections_lock(self): @@ -169,8 +321,8 @@ async def handle_connection_wrapper(self, reader, writer): def create_message(self, message_type: str, action: str = "", *args, **kwargs): return self.mm.create_message(message_type, action, *args, **kwargs) - async def handle_connection(self, reader, writer): - async def process_connection(reader, writer): + async def handle_connection(self, reader, writer, priority="medium"): + async def process_connection(reader, writer, priority="medium"): try: addr = writer.get_extra_info("peername") @@ -187,6 +339,15 @@ async def process_connection(reader, writer): f"πŸ”— [incoming] Connection from {addr} - {connection_addr} [id {connected_node_id} | port {connected_node_port} | direct {direct}] (incoming)" ) + blacklist = await self.bl.get_blacklist() + if blacklist: + logging.info(f"blacklist: {blacklist}, source trying to connect: {connection_addr}") + if connection_addr in blacklist: + logging.info(f"πŸ”— [incoming] Rejecting connection from {connection_addr}, it is blacklisted.") + writer.close() + await writer.wait_closed() + return + if self.id == connected_node_id: logging.info("πŸ”— [incoming] Connection with yourself is not allowed") writer.write(b"CONNECTION//CLOSE\n") @@ -244,7 +405,6 @@ async def process_connection(reader, writer): logging.info(f"πŸ”— [incoming] Creating new connection with {addr} (id {connected_node_id})") await writer.drain() connection = Connection( - self, reader, writer, connected_node_id, @@ -252,6 +412,7 @@ async def process_connection(reader, writer): connected_node_port, direct=direct, config=self.config, + prio=priority, ) async with self.connections_manager_lock: logging.info(f"πŸ”— [incoming] Including {connection_addr} in connections") @@ -277,7 +438,12 @@ async def process_connection(reader, writer): ) self.incoming_connections.pop(connection_addr) - await process_connection(reader, writer) + await process_connection(reader, writer, priority) + + async def terminate_failed_reconnection(self, conn: Connection): + connected_with = conn.addr + await self.bl.add_recently_disconnected(connected_with) + await self.disconnect(connected_with, mutual_disconnection=False) async def stop(self): logging.info("🌐 Stopping Communications Manager... [Removing connections and stopping network engine]") @@ -298,9 +464,18 @@ async def run_reconnections(self): connection["tries"] += 1 await self.connect(connection["addr"]) + async def clear_unused_undirect_connections(self): + async with self.connections_lock: + for conn in self.connections.values(): + if not conn.direct and await conn.is_inactive(): + logging.info(f"Cleaning unused connection: {conn.addr}") + asyncio.create_task(self.disconnect(conn.addr, mutual_disconnection=False)) + def verify_any_connections(self, neighbors): # Return True if any neighbors are connected - return bool(any(neighbor in self.connections for neighbor in neighbors)) + if any(neighbor in self.connections for neighbor in neighbors): + return True + return False def verify_connections(self, neighbors): # Return True if all neighbors are connected @@ -312,11 +487,7 @@ async def network_wait(self): async def deploy_additional_services(self): logging.info("🌐 Deploying additional services...") await self._forwarder.start() - if ( - self.config.participant["mobility_args"]["mobility"] - and self.config.participant["network_args"]["simulation"] - ): - pass + # await self._discoverer.start() # await self._health.start() self._propagator.start() @@ -325,7 +496,7 @@ async def include_received_message_hash(self, hash_message): try: await self.receive_messages_lock.acquire_async() if hash_message in self.received_messages_hashes: - logging.info(f"❗️ handle_incoming_message | Ignoring message already received.") + logging.info("❗️ handle_incoming_message | Ignoring message already received.") return False self.received_messages_hashes.append(hash_message) if len(self.received_messages_hashes) % 10000 == 0: @@ -350,51 +521,32 @@ async def send_message_to_neighbors(self, message, neighbors=None, interval=0): if interval > 0: await asyncio.sleep(interval) - async def send_message(self, dest_addr, message): - try: - conn = self.connections[dest_addr] - await conn.send(data=message) - except Exception as e: - logging.exception(f"❗️ Cannot send message {message} to {dest_addr}. Error: {e!s}") - await self.disconnect(dest_addr, mutual_disconnection=False) - - async def send_model(self, dest_addr, round, serialized_model, weight=1): - async with self.semaphore_send_model: + async def send_message(self, dest_addr, message, message_type=""): + is_compressed = message_type in _COMPRESSED_MESSAGES + if not is_compressed: try: - conn = self.connections.get(dest_addr) - if conn is None: - logging.info(f"❗️ Connection with {dest_addr} not found") - return - - logging.info( - f"Sending model to {dest_addr} with round {round}: weight={weight} |Β size={sys.getsizeof(serialized_model) / (1024** 2) if serialized_model is not None else 0} MB" - ) - parameters = serialized_model - message = self.create_message("model", "", round, parameters, weight) - await conn.send(data=message, is_compressed=True) - logging.info(f"Model sent to {dest_addr} with round {round}") - except Exception as e: - logging.exception(f"❗️ Cannot send model to {dest_addr}: {e!s}") - await self.disconnect(dest_addr, mutual_disconnection=False) - - async def send_offer_model(self, dest_addr, offer_message): - async with self.semaphore_send_model: - try: - conn = self.connections.get(dest_addr) - if conn is None: - logging.info(f"❗️ Connection with {dest_addr} not found") - return - logging.info(f"Sending offer model to {dest_addr}") - await conn.send(data=offer_message, is_compressed=True) - logging.info(f"Offer_Model sent to {dest_addr}") + if dest_addr in self.connections: + conn = self.connections[dest_addr] + await conn.send(data=message) except Exception as e: - logging.exception(f"❗️ Cannot send model to {dest_addr}: {e!s}") + logging.exception(f"❗️ Cannot send message {message} to {dest_addr}. Error: {e!s}") await self.disconnect(dest_addr, mutual_disconnection=False) + else: + async with self.semaphore_send_model: + try: + conn = self.connections.get(dest_addr) + if conn is None: + logging.info(f"❗️ Connection with {dest_addr} not found") + return + await conn.send(data=message, is_compressed=True) + except Exception as e: + logging.exception(f"❗️ Cannot send model to {dest_addr}: {e!s}") + await self.disconnect(dest_addr, mutual_disconnection=False) - async def establish_connection(self, addr, direct=True, reconnect=False): + async def establish_connection(self, addr, direct=True, reconnect=False, priority="medium"): logging.info(f"πŸ”— [outgoing] Establishing connection with {addr} (direct: {direct})") - async def process_establish_connection(addr, direct, reconnect): + async def process_establish_connection(addr, direct, reconnect, priority): try: host = str(addr.split(":")[0]) port = str(addr.split(":")[1]) @@ -402,10 +554,17 @@ async def process_establish_connection(addr, direct, reconnect): logging.info("πŸ”— [outgoing] Connection with yourself is not allowed") return False + blacklist = await self.bl.get_blacklist() + if blacklist: + logging.info(f"blacklist: {blacklist}, source trying to connect: {addr}") + if addr in blacklist: + logging.info(f"πŸ”— [incoming] Rejecting connection from {addr}, it is blacklisted.") + return + async with self.connections_manager_lock: if addr in self.connections: logging.info(f"πŸ”— [outgoing] Already connected with {self.connections[addr]}") - if not self.connections[addr].get_direct() and (direct is True): + if not self.connections[addr].get_direct() and (direct == True): self.connections[addr].set_direct(direct) return True else: @@ -488,7 +647,6 @@ async def process_establish_connection(addr, direct, reconnect): f"πŸ”— [outgoing] Creating new connection with {host}:{port} (id {connected_node_id})" ) connection = Connection( - self, reader, writer, connected_node_id, @@ -496,6 +654,7 @@ async def process_establish_connection(addr, direct, reconnect): port, direct=direct, config=self.config, + prio=priority, ) self.connections[addr] = connection await connection.start() @@ -509,7 +668,8 @@ async def process_establish_connection(addr, direct, reconnect): logging.info(f"πŸ”— [outgoing] Reconnection check is enabled on node {addr}") self.connections_reconnect.append({"addr": addr, "tries": 0}) - self.config.add_neighbor_from_config(addr) + if direct: + self.config.add_neighbor_from_config(addr) return True except Exception as e: logging.info(f"❗️ [outgoing] Error adding direct connected neighbor {addr}: {e!s}") @@ -529,9 +689,9 @@ async def process_establish_connection(addr, direct, reconnect): ) self.incoming_connections.pop(addr) - asyncio.create_task(process_establish_connection(addr, direct, reconnect)) + asyncio.create_task(process_establish_connection(addr, direct, reconnect, priority)) - async def connect(self, addr, direct=True): + async def connect(self, addr, direct=True, priority="medium"): await self.get_connections_lock().acquire_async() duplicated = addr in self.connections await self.get_connections_lock().release_async() @@ -539,18 +699,18 @@ async def connect(self, addr, direct=True): if direct: # Upcoming direct connection if not self.connections[addr].get_direct(): logging.info(f"πŸ”— [outgoing] Upgrading non direct connected neighbor {addr} to direct connection") - return await self.establish_connection(addr, direct=True, reconnect=False) + return await self.establish_connection(addr, direct=True, reconnect=False, priority=priority) else: # Upcoming undirected connection logging.info(f"πŸ”— [outgoing] Already direct connected neighbor {addr}, reconnecting...") - return await self.establish_connection(addr, direct=True, reconnect=False) + return await self.establish_connection(addr, direct=True, reconnect=False, priority=priority) else: logging.info(f"❗️ Cannot add a duplicate {addr} (undirected connection), already connected") return False else: if direct: - return await self.establish_connection(addr, direct=True, reconnect=False) + return await self.establish_connection(addr, direct=True, reconnect=False, priority=priority) else: - return await self.establish_connection(addr, direct=False, reconnect=False) + return await self.establish_connection(addr, direct=False, reconnect=False, priority=priority) async def register(self): data = {"node": self.addr} @@ -573,6 +733,10 @@ async def wait_for_controller(self): async def disconnect(self, dest_addr, mutual_disconnection=True, forced=False): removed = False + is_neighbor = dest_addr in await self.get_addrs_current_connections(only_direct=True, myself=True) + + if forced: + self.add_to_blacklist(dest_addr) logging.info(f"Trying to disconnect {dest_addr}") if dest_addr not in self.connections: @@ -582,7 +746,7 @@ async def disconnect(self, dest_addr, mutual_disconnection=True, forced=False): if mutual_disconnection: await self.connections[dest_addr].send(data=self.create_message("connection", "disconnect")) await asyncio.sleep(1) - self.connections[dest_addr].stop() + await self.connections[dest_addr].stop() except Exception as e: logging.exception(f"❗️ Error while disconnecting {dest_addr}: {e!s}") if dest_addr in self.connections: @@ -598,9 +762,11 @@ async def disconnect(self, dest_addr, mutual_disconnection=True, forced=False): current_connections = set(current_connections) logging.info(f"Current connections: {current_connections}") self.config.update_neighbors_from_config(current_connections, dest_addr) + if removed: current_connections = await self.get_addrs_current_connections(only_direct=True, myself=True) - await self.engine.update_neighbors(dest_addr, current_connections, remove=removed) + if is_neighbor: + await self.engine.update_neighbors(dest_addr, current_connections, remove=removed) async def remove_temporary_connection(self, temp_addr): logging.info(f"Removing temporary conneciton:{temp_addr}..") diff --git a/nebula/core/network/connection.py b/nebula/core/network/connection.py index 89a432eef..0a34ac526 100755 --- a/nebula/core/network/connection.py +++ b/nebula/core/network/connection.py @@ -7,12 +7,21 @@ import uuid import zlib from dataclasses import dataclass +from enum import Enum from typing import TYPE_CHECKING, Any import lz4.frame +from nebula.core.utils.locker import Locker + if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager + pass + + +class ConnectionPriority(Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" @dataclass @@ -28,10 +37,11 @@ class MessageChunk: class Connection: DEFAULT_FEDERATED_ROUND = -1 + INACTIVITY_TIMER = 30 + INACTIVITY_DAEMON_SLEEP_TIME = 20 def __init__( self, - cm: "CommunicationsManager", reader, writer, id, @@ -41,9 +51,8 @@ def __init__( active=True, compression="zlib", config=None, - prio="MEDIUM", + prio="medium", ): - self.cm = cm self.reader = reader self.writer = writer self.id = str(id) @@ -55,6 +64,7 @@ def __init__( self.last_active = time.time() self.compression = compression self.config = config + self._cm = None self.federated_round = Connection.DEFAULT_FEDERATED_ROUND self.loop = asyncio.get_event_loop() @@ -62,7 +72,10 @@ def __init__( self.process_task = None self.pending_messages_queue = asyncio.Queue(maxsize=100) self.message_buffers: dict[bytes, dict[int, MessageChunk]] = {} - self._prio = prio + self._prio: ConnectionPriority = ConnectionPriority(prio) + self._inactivity = False + self._last_activity = time.time() + self._activity_lock = Locker(name="activity_lock", async_lock=True) self.EOT_CHAR = b"\x00\x00\x00\x04" self.COMPRESSION_CHAR = b"\x00\x00\x00\x01" @@ -84,7 +97,7 @@ def __init__( ) def __str__(self): - return f"Connection to {self.addr} (id: {self.id}) (active: {self.active}) (last active: {self.last_active}) (direct: {self.direct})" + return f"Connection to {self.addr} (id: {self.id}) (active: {self.active}) (last active: {self.last_active}) (direct: {self.direct}) (priority: {self._prio.value})" def __repr__(self): return self.__str__() @@ -92,12 +105,46 @@ def __repr__(self): async def __del__(self): await self.stop() + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + def get_addr(self): return self.addr def get_prio(self): return self._prio + async def is_inactive(self): + async with self._activity_lock: + return self._inactivity + + async def _update_activity(self): + async with self._activity_lock: + self._last_activity = time.time() + self._inactivity = False + + async def _monitor_inactivity(self): + while True: + if self.direct: + break + await asyncio.sleep(self.INACTIVITY_DAEMON_SLEEP_TIME) + async with self._activity_lock: + time_since_last = time.time() - self._last_activity + if time_since_last > self.INACTIVITY_TIMER: + if not self._inactivity: + self._inactivity = True + logging.warning(f"[{self}] Connection marked as inactive.") + else: + if self._inactivity: + self._inactivity = False + def get_federated_round(self): return self.federated_round @@ -135,6 +182,7 @@ def get_last_active(self): async def start(self): self.read_task = asyncio.create_task(self.handle_incoming_message(), name=f"Connection {self.addr} reader") self.process_task = asyncio.create_task(self.process_message_queue(), name=f"Connection {self.addr} processor") + asyncio.create_task(self._monitor_inactivity()) async def stop(self): logging.info(f"❗️ Connection [stopped]: {self.addr} (id: {self.id})") @@ -156,7 +204,7 @@ async def stop(self): logging.exception(f"❗️ Error ocurred when closing pipe: {e}") async def reconnect(self, max_retries: int = 5, delay: int = 5) -> None: - if self.forced_disconnection: + if self.forced_disconnection or not self.direct: return self.incompleted_reconnections += 1 @@ -213,6 +261,7 @@ async def send( else: data_to_send = data_prefix + encoded_data + await self._update_activity() await self._send_chunks(message_id, data_to_send) except Exception as e: logging.exception(f"Error sending data: {e}") @@ -277,6 +326,7 @@ async def handle_incoming_message(self) -> None: message_id, chunk_index, is_last_chunk = self._parse_header(header) chunk_data = await self._read_chunk(reusable_buffer) + await self._update_activity() self._store_chunk(message_id, chunk_index, chunk_data, is_last_chunk) # logging.debug(f"Received chunk {chunk_index} of message {message_id.hex()} | size: {len(chunk_data)} bytes") # Active connection without fails @@ -289,11 +339,10 @@ async def handle_incoming_message(self) -> None: logging.exception(f"Connection closed while reading: {e}") except Exception as e: logging.exception(f"Error handling incoming message: {e}") - except BrokenPipeError as e: + except BrokenPipeError: logging.exception(f"Error handling incoming message: {e}") finally: - if self.direct: - # TODO tal vez una task? + if self.direct or self._prio == ConnectionPriority.HIGH: await self.reconnect() async def _read_exactly(self, num_bytes: int, max_retries: int = 3) -> bytes: diff --git a/nebula/core/network/discoverer.py b/nebula/core/network/discoverer.py index 8311d3aaa..b0f1e83cb 100755 --- a/nebula/core/network/discoverer.py +++ b/nebula/core/network/discoverer.py @@ -1,23 +1,29 @@ import asyncio import logging -from typing import TYPE_CHECKING from nebula.addons.functions import print_msg_box -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - class Discoverer: - def __init__(self, addr, config, cm: "CommunicationsManager"): + def __init__(self, addr, config): print_msg_box(msg="Starting discoverer module...", indent=2, title="Discoverer module") self.addr = addr self.config = config - self.cm = cm + self._cm = None self.grace_time = self.config.participant["discoverer_args"]["grace_time_discovery"] self.period = self.config.participant["discoverer_args"]["discovery_frequency"] self.interval = self.config.participant["discoverer_args"]["discovery_interval"] + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + async def start(self): asyncio.create_task(self.run_discover()) diff --git a/nebula/core/network/externalconnection/externalconnectionservice.py b/nebula/core/network/externalconnection/externalconnectionservice.py new file mode 100644 index 000000000..5fadf1f68 --- /dev/null +++ b/nebula/core/network/externalconnection/externalconnectionservice.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod + + +class ExternalConnectionService(ABC): + @abstractmethod + async def start(self): + pass + + @abstractmethod + async def stop(self): + pass + + @abstractmethod + def is_running(self): + pass + + @abstractmethod + async def find_federation(self): + pass + + @abstractmethod + async def start_beacon(self): + pass + + @abstractmethod + async def stop_beacon(self): + pass + + @abstractmethod + async def modify_beacon_frequency(self, frequency): + pass + + +class ExternalConnectionServiceException(Exception): + pass + + +def factory_connection_service(con_serv, addr) -> ExternalConnectionService: + from nebula.core.network.externalconnection.nebuladiscoveryservice import NebulaConnectionService + + CONNECTION_SERVICES = { + "nebula": NebulaConnectionService, + } + + con_serv = CONNECTION_SERVICES.get(con_serv, NebulaConnectionService) + + if con_serv: + return con_serv(addr) + else: + raise ExternalConnectionServiceException(f"Connection Service {con_serv} not found") diff --git a/nebula/core/network/externalconnection/nebuladiscoveryservice.py b/nebula/core/network/externalconnection/nebuladiscoveryservice.py new file mode 100644 index 000000000..b79b49c61 --- /dev/null +++ b/nebula/core/network/externalconnection/nebuladiscoveryservice.py @@ -0,0 +1,261 @@ +import asyncio +import logging +import socket +import struct + +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import BeaconRecievedEvent, ChangeLocationEvent +from nebula.core.network.externalconnection.externalconnectionservice import ExternalConnectionService + + +class NebulaServerProtocol(asyncio.DatagramProtocol): + BCAST_IP = "239.255.255.250" + UPNP_PORT = 1900 + DISCOVER_MESSAGE = "TYPE: discover" + BEACON_MESSAGE = "TYPE: beacon" + + def __init__(self, nebula_service, addr): + self.nebula_service: NebulaConnectionService = nebula_service + self.addr = addr + self.transport = None + + def connection_made(self, transport): + self.transport = transport + logging.info("Nebula UPnP server is listening...") + + def datagram_received(self, data, addr): + msg = data.decode("utf-8") + if self._is_nebula_message(msg): + # logging.info("Nebula message received...") + if self.DISCOVER_MESSAGE in msg: + logging.info("Discovery request received, responding...") + asyncio.create_task(self.respond(addr)) + elif self.BEACON_MESSAGE in msg: + asyncio.create_task(self.handle_beacon_received(msg)) + + async def respond(self, addr): + try: + response = ( + "HTTP/1.1 200 OK\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + "ST: urn:nebula-service\r\n" + "TYPE: response\r\n" + f"LOCATION: {self.addr}\r\n" + "\r\n" + ) + self.transport.sendto(response.encode("ASCII"), addr) + except Exception as e: + logging.exception(f"Error responding to client: {e}") + + async def handle_beacon_received(self, msg): + lines = msg.split("\r\n") + beacon_data = {} + + for line in lines: + if ": " in line: + key, value = line.split(": ", 1) + beacon_data[key] = value + + # Verify that it is not the beacon itself + beacon_addr = beacon_data.get("LOCATION") + if beacon_addr == self.addr: + return + + latitude = float(beacon_data.get("LATITUDE", 0.0)) + longitude = float(beacon_data.get("LONGITUDE", 0.0)) + await self.nebula_service.notify_beacon_received(beacon_addr, (latitude, longitude)) + + def _is_nebula_message(self, msg): + return "ST: urn:nebula-service" in msg + + +class NebulaClientProtocol(asyncio.DatagramProtocol): + BCAST_IP = "239.255.255.250" + BCAST_PORT = 1900 + SEARCH_TRIES = 3 + SEARCH_INTERVAL = 3 + + def __init__(self, nebula_service): + self.nebula_service: NebulaConnectionService = nebula_service + self.transport = None + self.search_done = asyncio.Event() + + def connection_made(self, transport): + self.transport = transport + sock = self.transport.get_extra_info("socket") + if sock is not None: + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + asyncio.create_task(self.keep_search()) + + async def keep_search(self): + logging.info("Federation searching loop started") + # while True: + for _ in range(self.SEARCH_TRIES): + await self.search() + await asyncio.sleep(self.SEARCH_INTERVAL) + self.search_done.set() + + async def wait_for_search(self): + await self.search_done.wait() + + async def search(self): + logging.info("Searching for nodes...") + try: + search_request = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + 'MAN: "ssdp:discover"\r\n' + "MX: 1\r\n" + "ST: urn:nebula-service\r\n" + "TYPE: discover\r\n" + "\r\n" + ) + self.transport.sendto(search_request.encode("ASCII"), (self.BCAST_IP, self.BCAST_PORT)) + except Exception as e: + logging.exception(f"Error sending search request: {e}") + + def datagram_received(self, data, addr): + try: + if "ST: urn:nebula-service" in data.decode("utf-8"): + # logging.info("Received response from Node server-service") + self.nebula_service.response_received(data, addr) + except UnicodeDecodeError: + logging.warning(f"Received malformed message from {addr}, ignoring.") + + +class NebulaBeacon: + def __init__(self, nebula_service, addr, interval=20): + self.nebula_service: NebulaConnectionService = nebula_service + self.addr = addr + self.interval = interval # Send interval in seconds + self.running = False + self._latitude = None + self._longitude = None + + async def start(self): + logging.info("[NebulaBeacon]: Starting sending pressence beacon") + self.running = True + await EventManager.get_instance().subscribe_addonevent(ChangeLocationEvent, self._proces_change_location_event) + while self.running: + await asyncio.sleep(self.interval) + await self.send_beacon() + + async def _proces_change_location_event(self, cle: ChangeLocationEvent): + lat, long = await cle.get_event_data() + # logging.info(f"Location changed to: ({lat},{long})") + self._latitude, self._longitude = lat, long + + async def stop(self): + logging.info("[NebulaBeacon]: Stop existance beacon") + self.running = False + + async def modify_beacon_frequency(self, frequency): + logging.info(f"[NebulaBeacon]: Changing beacon frequency from {self.interval}s to {frequency}s") + self.interval = frequency + + async def send_beacon(self): + latitude, longitude = self._latitude, self._longitude + try: + message = ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "ST: urn:nebula-service\r\n" + "TYPE: beacon\r\n" + f"LOCATION: {self.addr}\r\n" + f"LATITUDE: {latitude}\r\n" + f"LONGITUDE: {longitude}\r\n" + "\r\n" + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + sock.sendto(message.encode("ASCII"), ("239.255.255.250", 1900)) + sock.close() + logging.info("Beacon sent") + except Exception as e: + logging.exception(f"Error sending beacon: {e}") + + +class NebulaConnectionService(ExternalConnectionService): + def __init__(self, addr): + self.nodes_found = set() + self.addr = addr + self._cm = None + self.server: NebulaServerProtocol = None + self.client: NebulaClientProtocol = None + self.beacon: NebulaBeacon = NebulaBeacon(self, self.addr) + self.running = False + + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + + async def start(self): + loop = asyncio.get_running_loop() + transport, self.server = await loop.create_datagram_endpoint( + lambda: NebulaServerProtocol(self, self.addr), local_addr=("0.0.0.0", 1900) + ) + try: + # Advanced socket settings + sock = transport.get_extra_info("socket") + if sock is not None: + group = socket.inet_aton("239.255.255.250") # Multicast to binary format. + mreq = struct.pack("4sL", group, socket.INADDR_ANY) # Join multicast group in every interface available + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) # SO listen multicast packages + except Exception as e: + logging.exception(f"{e}") + self.running = True + + async def stop(self): + logging.info("Stop Nebula Connection Service") + if self.server and self.server.transport: + self.server.transport.close() + await self.beacon.stop() + self.running = False + + async def start_beacon(self): + if not self.beacon: + self.beacon = NebulaBeacon(self, self.addr) + asyncio.create_task(self.beacon.start()) + + async def stop_beacon(self): + if self.beacon: + await self.beacon.stop() + # self.beacon = None + + async def modify_beacon_frequency(self, frequency): + if self.beacon: + await self.beacon.modify_beacon_frequency(frequency=frequency) + + def is_running(self): + return self.running + + async def find_federation(self): + logging.info(f"Node {self.addr} trying to find federation...") + loop = asyncio.get_running_loop() + transport, self.client = await loop.create_datagram_endpoint( + lambda: NebulaClientProtocol(self), local_addr=("0.0.0.0", 0) + ) # To listen on all network interfaces + await self.client.wait_for_search() + transport.close() + return self.nodes_found + + def response_received(self, data, addr): + # logging.info("Parsing response...") + msg_str = data.decode("utf-8") + for line in msg_str.splitlines(): + if line.strip().startswith("LOCATION:"): + addr = line.split(": ")[1].strip() + if addr != self.addr: + if addr not in self.nodes_found: + logging.info(f"Device address received: {addr}") + self.nodes_found.add(addr) + + async def notify_beacon_received(self, addr, geoloc): + beacon_event = BeaconRecievedEvent(addr, geoloc) + asyncio.create_task(EventManager.get_instance().publish_node_event(beacon_event)) diff --git a/nebula/core/network/forwarder.py b/nebula/core/network/forwarder.py index dc31436e0..ebb48269a 100755 --- a/nebula/core/network/forwarder.py +++ b/nebula/core/network/forwarder.py @@ -1,20 +1,16 @@ import asyncio import logging import time -from typing import TYPE_CHECKING from nebula.addons.functions import print_msg_box from nebula.core.utils.locker import Locker -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - class Forwarder: - def __init__(self, config, cm: "CommunicationsManager"): + def __init__(self, config): print_msg_box(msg="Starting forwarder module...", indent=2, title="Forwarder module") self.config = config - self.cm = cm + self._cm = None self.pending_messages = asyncio.Queue() self.pending_messages_lock = Locker("pending_messages_lock", verbose=False, async_lock=True) @@ -22,6 +18,16 @@ def __init__(self, config, cm: "CommunicationsManager"): self.number_forwarded_messages = self.config.participant["forwarder_args"]["number_forwarded_messages"] self.messages_interval = self.config.participant["forwarder_args"]["forward_messages_interval"] + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + async def start(self): asyncio.create_task(self.run_forwarder()) diff --git a/nebula/core/network/health.py b/nebula/core/network/health.py index acd6a7784..554467e34 100755 --- a/nebula/core/network/health.py +++ b/nebula/core/network/health.py @@ -1,25 +1,31 @@ import asyncio import logging import time -from typing import TYPE_CHECKING from nebula.addons.functions import print_msg_box -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - class Health: - def __init__(self, addr, config, cm: "CommunicationsManager"): + def __init__(self, addr, config): print_msg_box(msg="Starting health module...", indent=2, title="Health module") self.addr = addr self.config = config - self.cm = cm + self._cm = None self.period = self.config.participant["health_args"]["health_interval"] self.alive_interval = self.config.participant["health_args"]["send_alive_interval"] self.check_alive_interval = self.config.participant["health_args"]["check_alive_interval"] self.timeout = self.config.participant["health_args"]["alive_timeout"] + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + async def start(self): asyncio.create_task(self.run_send_alive()) asyncio.create_task(self.run_check_alive()) diff --git a/nebula/core/network/messages.py b/nebula/core/network/messages.py index 72880f568..7e60a3feb 100644 --- a/nebula/core/network/messages.py +++ b/nebula/core/network/messages.py @@ -1,187 +1,204 @@ -import hashlib -import logging -import traceback -from typing import TYPE_CHECKING - -from nebula.core.nebulaevents import MessageEvent -from nebula.core.network.actions import factory_message_action, get_action_name_from_value, get_actions_names -from nebula.core.pb import nebula_pb2 - -if TYPE_CHECKING: - from nebula.core.network.communications import CommunicationsManager - - -class MessagesManager: - def __init__(self, addr, config, cm: "CommunicationsManager"): - self.addr = addr - self.config = config - self.cm = cm - self._message_templates = {} - self._define_message_templates() - - def _define_message_templates(self): - # Dictionary that maps message types to their required parameters and default values - self._message_templates = { - "connection": {"parameters": ["action"], "defaults": {}}, - "discovery": { - "parameters": ["action", "latitude", "longitude"], - "defaults": { - "latitude": 0.0, - "longitude": 0.0, - }, - }, - "control": { - "parameters": ["action", "log"], - "defaults": { - "log": "Control message", - }, - }, - "federation": { - "parameters": ["action", "arguments", "round"], - "defaults": { - "arguments": [], - "round": None, - }, - }, - "model": { - "parameters": ["round", "parameters", "weight"], - "defaults": { - "weight": 1, - }, - }, - "reputation": { - "parameters": ["node_id", "score", "round", "action"], - "defaults": { - "round": None, - }, - }, - # Add additional message types here - } - - def get_messages_events(self): - message_events = {} - for message_name in self._message_templates: - if message_name != "model": - message_events[message_name] = get_actions_names(message_name) - return message_events - - async def process_message(self, data, addr_from): - not_processing_messages = {"control_message", "connection_message"} - special_processing_messages = {"discovery_message", "federation_message", "model_message"} - - try: - message_wrapper = nebula_pb2.Wrapper() - message_wrapper.ParseFromString(data) - source = message_wrapper.source - logging.debug(f"πŸ“₯ handle_incoming_message | Received message from {addr_from} with source {source}") - if source == self.addr: - return - - # Extract the active message from the oneof field - message_type = message_wrapper.WhichOneof("message") - msg_name = message_type.split("_")[0] - if not message_type: - logging.warning("Received message with no active field in the 'oneof'") - return - - message_data = getattr(message_wrapper, message_type) - - # Not required processing messages - if message_type in not_processing_messages: - # await self.cm.handle_message(source, message_type, message_data) - me = MessageEvent( - (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data - ) - await self.cm.handle_message(me) - - # Message-specific forwarding and processing - elif message_type in special_processing_messages: - if await self.cm.include_received_message_hash(hashlib.md5(data).hexdigest()): - # Forward the message if required - if self._should_forward_message(message_type, message_wrapper): - await self.cm.forward_message(data, addr_from) - - if message_type == "model_message": - await self.cm.handle_model_message(source, message_data) - else: - me = MessageEvent( - (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data - ) - await self.cm.handle_message(me) - # Rest of messages - else: - if await self.cm.include_received_message_hash(hashlib.md5(data).hexdigest()): - me = MessageEvent( - (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data - ) - await self.cm.handle_message(me) - except Exception as e: - logging.exception(f"πŸ“₯ handle_incoming_message | Error while processing: {e}") - logging.exception(traceback.format_exc()) - - def _should_forward_message(self, message_type, message_wrapper): - if self.cm.config.participant["device_args"]["proxy"]: - return True - # TODO: Improve the technique. Now only forward model messages if the node is a proxy - # Need to update the expected model messages receiving during the round - # Round -1 is the initialization round --> all nodes should receive the model - if message_type == "model_message" and message_wrapper.model_message.round == -1: - return True - if ( - message_type == "federation_message" - and message_wrapper.federation_message.action - == nebula_pb2.FederationMessage.Action.Value("FEDERATION_START") - ): - return True - - def create_message(self, message_type: str, action: str = "", *args, **kwargs): - # logging.info(f"Creating message | type: {message_type}, action: {action}, positionals: {args}, explicits: {kwargs.keys()}") - # If an action is provided, convert it to its corresponding enum value using the factory - message_action = None - if action: - message_action = factory_message_action(message_type, action) - - # Retrieve the template for the provided message type - message_template = self._message_templates.get(message_type) - if not message_template: - raise ValueError(f"Invalid message type '{message_type}'") - - # Extract parameters and defaults from the template - template_params = message_template["parameters"] - default_values: dict = message_template.get("defaults", {}) - - # Dynamically retrieve the class for the protobuf message (e.g., OfferMessage) - class_name = message_type.capitalize() + "Message" - message_class = getattr(nebula_pb2, class_name, None) - - if message_class is None: - raise AttributeError(f"Message type {message_type} not found on the protocol") - - # Set the 'action' parameter if required and if the message_action is available - if "action" in template_params and message_action is not None: - kwargs["action"] = message_action - - # Map positional arguments to template parameters - remaining_params = [param_name for param_name in template_params if param_name not in kwargs] - if args: - for param_name, arg_value in zip(remaining_params, args, strict=False): - if param_name in kwargs: - continue - kwargs[param_name] = arg_value - - # Fill in missing parameters with their default values - # logging.info(f"kwargs parameters: {kwargs.keys()}") - for param_name in template_params: - if param_name not in kwargs: - # logging.info(f"Filling parameter '{param_name}' with default value: {default_values.get(param_name)}") - kwargs[param_name] = default_values.get(param_name) - - # Create an instance of the protobuf message class using the constructed kwargs - message = message_class(**kwargs) - - message_wrapper = nebula_pb2.Wrapper() - message_wrapper.source = self.addr - field_name = f"{message_type}_message" - getattr(message_wrapper, field_name).CopyFrom(message) - data = message_wrapper.SerializeToString() - return data +import hashlib +import logging +import traceback + +from nebula.core.nebulaevents import MessageEvent +from nebula.core.network.actions import factory_message_action, get_action_name_from_value, get_actions_names +from nebula.core.pb import nebula_pb2 + + +class MessagesManager: + def __init__(self, addr, config): + self.addr = addr + self.config = config + self._cm = None + self._message_templates = {} + self._define_message_templates() + + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + + def _define_message_templates(self): + # Dictionary that maps message types to their required parameters and default values + self._message_templates = { + "offer": { + "parameters": ["action", "n_neighbors", "loss", "parameters", "rounds", "round", "epochs"], + "defaults": { + "parameters": None, + "rounds": 1, + "round": -1, + "epochs": 1, + }, + }, + "connection": {"parameters": ["action"], "defaults": {}}, + "discovery": { + "parameters": ["action", "latitude", "longitude"], + "defaults": { + "latitude": 0.0, + "longitude": 0.0, + }, + }, + "control": { + "parameters": ["action", "log"], + "defaults": { + "log": "Control message", + }, + }, + "federation": { + "parameters": ["action", "arguments", "round"], + "defaults": { + "arguments": [], + "round": None, + }, + }, + "model": { + "parameters": ["round", "parameters", "weight"], + "defaults": { + "weight": 1, + }, + }, + "reputation": { + "parameters": ["node_id", "score", "round", "action"], + "defaults": { + "round": None, + }, + }, + "discover": {"parameters": ["action"], "defaults": {}}, + "link": {"parameters": ["action", "addrs"], "defaults": {}}, + # Add additional message types here + } + + def get_messages_events(self): + message_events = {} + for message_name in self._message_templates: + if message_name != "model": + message_events[message_name] = get_actions_names(message_name) + return message_events + + async def process_message(self, data, addr_from): + not_processing_messages = {"control_message", "connection_message"} + special_processing_messages = {"discovery_message", "federation_message", "model_message"} + + try: + message_wrapper = nebula_pb2.Wrapper() + message_wrapper.ParseFromString(data) + source = message_wrapper.source + logging.debug(f"πŸ“₯ handle_incoming_message | Received message from {addr_from} with source {source}") + if source == self.addr: + return + + # Extract the active message from the oneof field + message_type = message_wrapper.WhichOneof("message") + msg_name = message_type.split("_")[0] + if not message_type: + logging.warning("Received message with no active field in the 'oneof'") + return + + message_data = getattr(message_wrapper, message_type) + + # Not required processing messages + if message_type in not_processing_messages: + # await self.cm.handle_message(source, message_type, message_data) + me = MessageEvent( + (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data + ) + await self.cm.handle_message(me) + + # Message-specific forwarding and processing + elif message_type in special_processing_messages: + if await self.cm.include_received_message_hash(hashlib.md5(data).hexdigest()): + # Forward the message if required + if self._should_forward_message(message_type, message_wrapper): + await self.cm.forward_message(data, addr_from) + + if message_type == "model_message": + await self.cm.handle_model_message(source, message_data) + else: + me = MessageEvent( + (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data + ) + await self.cm.handle_message(me) + # Rest of messages + else: + # if await self.cm.include_received_message_hash(hashlib.md5(data).hexdigest()): + me = MessageEvent( + (msg_name, get_action_name_from_value(msg_name, message_data.action)), source, message_data + ) + await self.cm.handle_message(me) + except Exception as e: + logging.exception(f"πŸ“₯ handle_incoming_message | Error while processing: {e}") + logging.exception(traceback.format_exc()) + + def _should_forward_message(self, message_type, message_wrapper): + if self.cm.config.participant["device_args"]["proxy"]: + return True + # TODO: Improve the technique. Now only forward model messages if the node is a proxy + # Need to update the expected model messages receiving during the round + # Round -1 is the initialization round --> all nodes should receive the model + if message_type == "model_message" and message_wrapper.model_message.round == -1: + return True + if ( + message_type == "federation_message" + and message_wrapper.federation_message.action + == nebula_pb2.FederationMessage.Action.Value("FEDERATION_START") + ): + return True + + def create_message(self, message_type: str, action: str = "", *args, **kwargs): + # logging.info(f"Creating message | type: {message_type}, action: {action}, positionals: {args}, explicits: {kwargs.keys()}") + # If an action is provided, convert it to its corresponding enum value using the factory + message_action = None + if action: + message_action = factory_message_action(message_type, action) + + # Retrieve the template for the provided message type + message_template = self._message_templates.get(message_type) + if not message_template: + raise ValueError(f"Invalid message type '{message_type}'") + + # Extract parameters and defaults from the template + template_params = message_template["parameters"] + default_values: dict = message_template.get("defaults", {}) + + # Dynamically retrieve the class for the protobuf message (e.g., OfferMessage) + class_name = message_type.capitalize() + "Message" + message_class = getattr(nebula_pb2, class_name, None) + + if message_class is None: + raise AttributeError(f"Message type {message_type} not found on the protocol") + + # Set the 'action' parameter if required and if the message_action is available + if "action" in template_params and message_action is not None: + kwargs["action"] = message_action + + # Map positional arguments to template parameters + remaining_params = [param_name for param_name in template_params if param_name not in kwargs] + if args: + for param_name, arg_value in zip(remaining_params, args, strict=False): + if param_name in kwargs: + continue + kwargs[param_name] = arg_value + + # Fill in missing parameters with their default values + # logging.info(f"kwargs parameters: {kwargs.keys()}") + for param_name in template_params: + if param_name not in kwargs: + # logging.info(f"Filling parameter '{param_name}' with default value: {default_values.get(param_name)}") + kwargs[param_name] = default_values.get(param_name) + + # Create an instance of the protobuf message class using the constructed kwargs + message = message_class(**kwargs) + + message_wrapper = nebula_pb2.Wrapper() + message_wrapper.source = self.addr + field_name = f"{message_type}_message" + getattr(message_wrapper, field_name).CopyFrom(message) + data = message_wrapper.SerializeToString() + return data diff --git a/nebula/core/network/propagator.py b/nebula/core/network/propagator.py index 9ab01182e..02a1f74de 100755 --- a/nebula/core/network/propagator.py +++ b/nebula/core/network/propagator.py @@ -1,5 +1,6 @@ import asyncio import logging +import sys from abc import ABC, abstractmethod from collections import deque from typing import TYPE_CHECKING, Any @@ -10,7 +11,6 @@ from nebula.config.config import Config from nebula.core.aggregation.aggregator import Aggregator from nebula.core.engine import Engine - from nebula.core.network.communications import CommunicationsManager from nebula.core.training.lightning import Lightning @@ -63,11 +63,23 @@ def prepare_model_payload(self, node: str) -> tuple[Any, float] | None: class Propagator: - def __init__(self, cm: "CommunicationsManager"): - self.engine: Engine = cm.engine - self.config: Config = cm.get_config() - self.addr = cm.get_addr() - self.cm: CommunicationsManager = cm + def __init__(self): + self._cm = None + + @property + def cm(self): + if not self._cm: + from nebula.core.network.communications import CommunicationsManager + + self._cm = CommunicationsManager.get_instance() + return self._cm + else: + return self._cm + + def start(self): + self.engine: Engine = self.cm.engine + self.config: Config = self.cm.get_config() + self.addr = self.cm.get_addr() self.aggregator: Aggregator = self.engine.aggregator self.trainer: Lightning = self.engine._trainer @@ -83,8 +95,6 @@ def __init__(self, cm: "CommunicationsManager"): "initialization": InitialModelPropagation(self.aggregator, self.trainer, self.engine), "stable": StableModelPropagation(self.aggregator, self.trainer, self.engine), } - - def start(self): print_msg_box( msg="Starting propagator functionality...\nModel propagation through the network", indent=2, @@ -159,12 +169,14 @@ async def propagate(self, strategy_id: str): serialized_model = None round_number = -1 if strategy_id == "initialization" else self.get_round() - + parameters = serialized_model + message = self.cm.create_message("model", "", round_number, parameters, weight) for neighbor_addr in eligible_neighbors: - asyncio.create_task(self.cm.send_model(neighbor_addr, round_number, serialized_model, weight)) - - # if len(self.aggregator.get_nodes_pending_models_to_aggregate()) >= len(self.aggregator._federation_nodes): - # return False + logging.info( + f"Sending model to {neighbor_addr} with round {self.get_round()}: weight={weight} |Β size={sys.getsizeof(serialized_model) / (1024** 2) if serialized_model is not None else 0} MB" + ) + asyncio.create_task(self.cm.send_message(neighbor_addr, message, "model")) + # asyncio.create_task(self.cm.send_model(neighbor_addr, round_number, serialized_model, weight)) await asyncio.sleep(self.interval) return True diff --git a/nebula/node.py b/nebula/core/node.py similarity index 96% rename from nebula/node.py rename to nebula/core/node.py index 7f60db200..f4a296520 100755 --- a/nebula/node.py +++ b/nebula/core/node.py @@ -12,7 +12,7 @@ warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning) -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..")) import logging from nebula.config.config import Config @@ -220,15 +220,16 @@ def randomize_value(value, variability): security=False, ) await node.start_communications() + await node.deploy_components() await node.deploy_federation() # If it is an additional node, it should wait until additional_node_round to connect to the network # In order to do that, it should request the current round to the controller if additional_node_status: - logging.info(f"Waiting for round {additional_node_round} to start") - logging.info("Waiting time to start finding federation") - - await asyncio.sleep(6000) # DEBUG purposes + time = config.participant["mobility_args"]["additional_node"]["time_start"] + logging.info(f"Waiting time to start finding federation: {time}") + await asyncio.sleep(int(config.participant["mobility_args"]["additional_node"]["time_start"])) + await node._aditional_node_start() if node.cm is not None: await node.cm.network_wait() diff --git a/nebula/core/pb/nebula.proto b/nebula/core/pb/nebula.proto index 4436be3dc..fe9d44c1e 100755 --- a/nebula/core/pb/nebula.proto +++ b/nebula/core/pb/nebula.proto @@ -85,6 +85,8 @@ message DiscoverMessage { enum Action { DISCOVER_JOIN = 0; // Message to discover nodes on federation when i'm new DISCOVER_NODES = 1; // Message to discover nodes on federation when i'm already in + LATE_CONNECT = 2; // Message send when late connection to federation + RESTRUCTURE = 3; // Message to notify connection is because restructuration of topology } Action action = 1; } @@ -112,11 +114,6 @@ message LinkMessage { string addrs = 2; } -// Response transmits the outcome of a requested operation, including any errors. -message ResponseMessage { - string response = 1; // Outcome of the requested operation. -} - message ReputationMessage { enum Action { SHARE = 0; @@ -126,3 +123,8 @@ message ReputationMessage { int32 round = 3; //Round to send the reputation Action action = 4; // Action type (default: SHARE) } + +// Response transmits the outcome of a requested operation, including any errors. +message ResponseMessage { + string response = 1; // Outcome of the requested operation. +} diff --git a/nebula/core/pb/nebula_pb2.py b/nebula/core/pb/nebula_pb2.py index b181f94d3..93ae692f6 100644 --- a/nebula/core/pb/nebula_pb2.py +++ b/nebula/core/pb/nebula_pb2.py @@ -1,158 +1,63 @@ -# -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: nebula.proto +# Protobuf Python Version: 4.25.3 """Generated protocol buffer code.""" + from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import message as _message -from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x0cnebula.proto\x12\x06nebula"\xae\x04\n\x07Wrapper\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x35\n\x11\x64iscovery_message\x18\x02 \x01(\x0b\x32\x18.nebula.DiscoveryMessageH\x00\x12\x31\n\x0f\x63ontrol_message\x18\x03 \x01(\x0b\x32\x16.nebula.ControlMessageH\x00\x12\x37\n\x12\x66\x65\x64\x65ration_message\x18\x04 \x01(\x0b\x32\x19.nebula.FederationMessageH\x00\x12-\n\rmodel_message\x18\x05 \x01(\x0b\x32\x14.nebula.ModelMessageH\x00\x12\x37\n\x12\x63onnection_message\x18\x06 \x01(\x0b\x32\x19.nebula.ConnectionMessageH\x00\x12\x33\n\x10response_message\x18\x07 \x01(\x0b\x32\x17.nebula.ResponseMessageH\x00\x12\x37\n\x12reputation_message\x18\x08 \x01(\x0b\x32\x19.nebula.ReputationMessageH\x00\x12\x33\n\x10\x64iscover_message\x18\t \x01(\x0b\x32\x17.nebula.DiscoverMessageH\x00\x12-\n\roffer_message\x18\n \x01(\x0b\x32\x14.nebula.OfferMessageH\x00\x12+\n\x0clink_message\x18\x0b \x01(\x0b\x32\x13.nebula.LinkMessageH\x00\x42\t\n\x07message"\x9e\x01\n\x10\x44iscoveryMessage\x12/\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1f.nebula.DiscoveryMessage.Action\x12\x10\n\x08latitude\x18\x02 \x01(\x02\x12\x11\n\tlongitude\x18\x03 \x01(\x02"4\n\x06\x41\x63tion\x12\x0c\n\x08\x44ISCOVER\x10\x00\x12\x0c\n\x08REGISTER\x10\x01\x12\x0e\n\nDEREGISTER\x10\x02"\x9a\x01\n\x0e\x43ontrolMessage\x12-\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1d.nebula.ControlMessage.Action\x12\x0b\n\x03log\x18\x02 \x01(\t"L\n\x06\x41\x63tion\x12\t\n\x05\x41LIVE\x10\x00\x12\x0c\n\x08OVERHEAD\x10\x01\x12\x0c\n\x08MOBILITY\x10\x02\x12\x0c\n\x08RECOVERY\x10\x03\x12\r\n\tWEAK_LINK\x10\x04"\xcd\x01\n\x11\x46\x65\x64\x65rationMessage\x12\x30\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32 .nebula.FederationMessage.Action\x12\x11\n\targuments\x18\x02 \x03(\t\x12\r\n\x05round\x18\x03 \x01(\x05"d\n\x06\x41\x63tion\x12\x14\n\x10\x46\x45\x44\x45RATION_START\x10\x00\x12\x0e\n\nREPUTATION\x10\x01\x12\x1e\n\x1a\x46\x45\x44\x45RATION_MODELS_INCLUDED\x10\x02\x12\x14\n\x10\x46\x45\x44\x45RATION_READY\x10\x03"A\n\x0cModelMessage\x12\x12\n\nparameters\x18\x01 \x01(\x0c\x12\x0e\n\x06weight\x18\x02 \x01(\x03\x12\r\n\x05round\x18\x03 \x01(\x05"\x8f\x01\n\x11\x43onnectionMessage\x12\x30\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32 .nebula.ConnectionMessage.Action"H\n\x06\x41\x63tion\x12\x0b\n\x07\x43ONNECT\x10\x00\x12\x0e\n\nDISCONNECT\x10\x01\x12\x10\n\x0cLATE_CONNECT\x10\x02\x12\x0f\n\x0bRESTRUCTURE\x10\x03"\x95\x01\n\x0f\x44iscoverMessage\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.nebula.DiscoverMessage.Action"R\n\x06\x41\x63tion\x12\x11\n\rDISCOVER_JOIN\x10\x00\x12\x12\n\x0e\x44ISCOVER_NODES\x10\x01\x12\x10\n\x0cLATE_CONNECT\x10\x02\x12\x0f\n\x0bRESTRUCTURE\x10\x03"\xce\x01\n\x0cOfferMessage\x12+\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1b.nebula.OfferMessage.Action\x12\x13\n\x0bn_neighbors\x18\x02 \x01(\x02\x12\x0c\n\x04loss\x18\x03 \x01(\x02\x12\x12\n\nparameters\x18\x04 \x01(\x0c\x12\x0e\n\x06rounds\x18\x05 \x01(\x05\x12\r\n\x05round\x18\x06 \x01(\x05\x12\x0e\n\x06\x65pochs\x18\x07 \x01(\x05"+\n\x06\x41\x63tion\x12\x0f\n\x0bOFFER_MODEL\x10\x00\x12\x10\n\x0cOFFER_METRIC\x10\x01"w\n\x0bLinkMessage\x12*\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1a.nebula.LinkMessage.Action\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x01(\t"-\n\x06\x41\x63tion\x12\x0e\n\nCONNECT_TO\x10\x00\x12\x13\n\x0f\x44ISCONNECT_FROM\x10\x01"\x89\x01\n\x11ReputationMessage\x12\x0f\n\x07node_id\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x02\x12\r\n\x05round\x18\x03 \x01(\x05\x12\x30\n\x06\x61\x63tion\x18\x04 \x01(\x0e\x32 .nebula.ReputationMessage.Action"\x13\n\x06\x41\x63tion\x12\t\n\x05SHARE\x10\x00"#\n\x0fResponseMessage\x12\x10\n\x08response\x18\x01 \x01(\tb\x06proto3' +) - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cnebula.proto\x12\x06nebula\"\xae\x04\n\x07Wrapper\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x35\n\x11\x64iscovery_message\x18\x02 \x01(\x0b\x32\x18.nebula.DiscoveryMessageH\x00\x12\x31\n\x0f\x63ontrol_message\x18\x03 \x01(\x0b\x32\x16.nebula.ControlMessageH\x00\x12\x37\n\x12\x66\x65\x64\x65ration_message\x18\x04 \x01(\x0b\x32\x19.nebula.FederationMessageH\x00\x12-\n\rmodel_message\x18\x05 \x01(\x0b\x32\x14.nebula.ModelMessageH\x00\x12\x37\n\x12\x63onnection_message\x18\x06 \x01(\x0b\x32\x19.nebula.ConnectionMessageH\x00\x12\x33\n\x10response_message\x18\x07 \x01(\x0b\x32\x17.nebula.ResponseMessageH\x00\x12\x37\n\x12reputation_message\x18\x08 \x01(\x0b\x32\x19.nebula.ReputationMessageH\x00\x12\x33\n\x10\x64iscover_message\x18\t \x01(\x0b\x32\x17.nebula.DiscoverMessageH\x00\x12-\n\roffer_message\x18\n \x01(\x0b\x32\x14.nebula.OfferMessageH\x00\x12+\n\x0clink_message\x18\x0b \x01(\x0b\x32\x13.nebula.LinkMessageH\x00\x42\t\n\x07message\"\x9e\x01\n\x10\x44iscoveryMessage\x12/\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1f.nebula.DiscoveryMessage.Action\x12\x10\n\x08latitude\x18\x02 \x01(\x02\x12\x11\n\tlongitude\x18\x03 \x01(\x02\"4\n\x06\x41\x63tion\x12\x0c\n\x08\x44ISCOVER\x10\x00\x12\x0c\n\x08REGISTER\x10\x01\x12\x0e\n\nDEREGISTER\x10\x02\"\x9a\x01\n\x0e\x43ontrolMessage\x12-\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1d.nebula.ControlMessage.Action\x12\x0b\n\x03log\x18\x02 \x01(\t\"L\n\x06\x41\x63tion\x12\t\n\x05\x41LIVE\x10\x00\x12\x0c\n\x08OVERHEAD\x10\x01\x12\x0c\n\x08MOBILITY\x10\x02\x12\x0c\n\x08RECOVERY\x10\x03\x12\r\n\tWEAK_LINK\x10\x04\"\xcd\x01\n\x11\x46\x65\x64\x65rationMessage\x12\x30\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32 .nebula.FederationMessage.Action\x12\x11\n\targuments\x18\x02 \x03(\t\x12\r\n\x05round\x18\x03 \x01(\x05\"d\n\x06\x41\x63tion\x12\x14\n\x10\x46\x45\x44\x45RATION_START\x10\x00\x12\x0e\n\nREPUTATION\x10\x01\x12\x1e\n\x1a\x46\x45\x44\x45RATION_MODELS_INCLUDED\x10\x02\x12\x14\n\x10\x46\x45\x44\x45RATION_READY\x10\x03\"A\n\x0cModelMessage\x12\x12\n\nparameters\x18\x01 \x01(\x0c\x12\x0e\n\x06weight\x18\x02 \x01(\x03\x12\r\n\x05round\x18\x03 \x01(\x05\"\x8f\x01\n\x11\x43onnectionMessage\x12\x30\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32 .nebula.ConnectionMessage.Action\"H\n\x06\x41\x63tion\x12\x0b\n\x07\x43ONNECT\x10\x00\x12\x0e\n\nDISCONNECT\x10\x01\x12\x10\n\x0cLATE_CONNECT\x10\x02\x12\x0f\n\x0bRESTRUCTURE\x10\x03\"r\n\x0f\x44iscoverMessage\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.nebula.DiscoverMessage.Action\"/\n\x06\x41\x63tion\x12\x11\n\rDISCOVER_JOIN\x10\x00\x12\x12\n\x0e\x44ISCOVER_NODES\x10\x01\"\xce\x01\n\x0cOfferMessage\x12+\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1b.nebula.OfferMessage.Action\x12\x13\n\x0bn_neighbors\x18\x02 \x01(\x02\x12\x0c\n\x04loss\x18\x03 \x01(\x02\x12\x12\n\nparameters\x18\x04 \x01(\x0c\x12\x0e\n\x06rounds\x18\x05 \x01(\x05\x12\r\n\x05round\x18\x06 \x01(\x05\x12\x0e\n\x06\x65pochs\x18\x07 \x01(\x05\"+\n\x06\x41\x63tion\x12\x0f\n\x0bOFFER_MODEL\x10\x00\x12\x10\n\x0cOFFER_METRIC\x10\x01\"w\n\x0bLinkMessage\x12*\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1a.nebula.LinkMessage.Action\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x01(\t\"-\n\x06\x41\x63tion\x12\x0e\n\nCONNECT_TO\x10\x00\x12\x13\n\x0f\x44ISCONNECT_FROM\x10\x01\"#\n\x0fResponseMessage\x12\x10\n\x08response\x18\x01 \x01(\t\"\x89\x01\n\x11ReputationMessage\x12\x0f\n\x07node_id\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x02\x12\r\n\x05round\x18\x03 \x01(\x05\x12\x30\n\x06\x61\x63tion\x18\x04 \x01(\x0e\x32 .nebula.ReputationMessage.Action\"\x13\n\x06\x41\x63tion\x12\t\n\x05SHARE\x10\x00\x62\x06proto3') - - - -_WRAPPER = DESCRIPTOR.message_types_by_name['Wrapper'] -_DISCOVERYMESSAGE = DESCRIPTOR.message_types_by_name['DiscoveryMessage'] -_CONTROLMESSAGE = DESCRIPTOR.message_types_by_name['ControlMessage'] -_FEDERATIONMESSAGE = DESCRIPTOR.message_types_by_name['FederationMessage'] -_MODELMESSAGE = DESCRIPTOR.message_types_by_name['ModelMessage'] -_CONNECTIONMESSAGE = DESCRIPTOR.message_types_by_name['ConnectionMessage'] -_DISCOVERMESSAGE = DESCRIPTOR.message_types_by_name['DiscoverMessage'] -_OFFERMESSAGE = DESCRIPTOR.message_types_by_name['OfferMessage'] -_LINKMESSAGE = DESCRIPTOR.message_types_by_name['LinkMessage'] -_RESPONSEMESSAGE = DESCRIPTOR.message_types_by_name['ResponseMessage'] -_REPUTATIONMESSAGE = DESCRIPTOR.message_types_by_name['ReputationMessage'] -_DISCOVERYMESSAGE_ACTION = _DISCOVERYMESSAGE.enum_types_by_name['Action'] -_CONTROLMESSAGE_ACTION = _CONTROLMESSAGE.enum_types_by_name['Action'] -_FEDERATIONMESSAGE_ACTION = _FEDERATIONMESSAGE.enum_types_by_name['Action'] -_CONNECTIONMESSAGE_ACTION = _CONNECTIONMESSAGE.enum_types_by_name['Action'] -_DISCOVERMESSAGE_ACTION = _DISCOVERMESSAGE.enum_types_by_name['Action'] -_OFFERMESSAGE_ACTION = _OFFERMESSAGE.enum_types_by_name['Action'] -_LINKMESSAGE_ACTION = _LINKMESSAGE.enum_types_by_name['Action'] -_REPUTATIONMESSAGE_ACTION = _REPUTATIONMESSAGE.enum_types_by_name['Action'] -Wrapper = _reflection.GeneratedProtocolMessageType('Wrapper', (_message.Message,), { - 'DESCRIPTOR' : _WRAPPER, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.Wrapper) - }) -_sym_db.RegisterMessage(Wrapper) - -DiscoveryMessage = _reflection.GeneratedProtocolMessageType('DiscoveryMessage', (_message.Message,), { - 'DESCRIPTOR' : _DISCOVERYMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.DiscoveryMessage) - }) -_sym_db.RegisterMessage(DiscoveryMessage) - -ControlMessage = _reflection.GeneratedProtocolMessageType('ControlMessage', (_message.Message,), { - 'DESCRIPTOR' : _CONTROLMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.ControlMessage) - }) -_sym_db.RegisterMessage(ControlMessage) - -FederationMessage = _reflection.GeneratedProtocolMessageType('FederationMessage', (_message.Message,), { - 'DESCRIPTOR' : _FEDERATIONMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.FederationMessage) - }) -_sym_db.RegisterMessage(FederationMessage) - -ModelMessage = _reflection.GeneratedProtocolMessageType('ModelMessage', (_message.Message,), { - 'DESCRIPTOR' : _MODELMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.ModelMessage) - }) -_sym_db.RegisterMessage(ModelMessage) - -ConnectionMessage = _reflection.GeneratedProtocolMessageType('ConnectionMessage', (_message.Message,), { - 'DESCRIPTOR' : _CONNECTIONMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.ConnectionMessage) - }) -_sym_db.RegisterMessage(ConnectionMessage) - -DiscoverMessage = _reflection.GeneratedProtocolMessageType('DiscoverMessage', (_message.Message,), { - 'DESCRIPTOR' : _DISCOVERMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.DiscoverMessage) - }) -_sym_db.RegisterMessage(DiscoverMessage) - -OfferMessage = _reflection.GeneratedProtocolMessageType('OfferMessage', (_message.Message,), { - 'DESCRIPTOR' : _OFFERMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.OfferMessage) - }) -_sym_db.RegisterMessage(OfferMessage) - -LinkMessage = _reflection.GeneratedProtocolMessageType('LinkMessage', (_message.Message,), { - 'DESCRIPTOR' : _LINKMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.LinkMessage) - }) -_sym_db.RegisterMessage(LinkMessage) - -ResponseMessage = _reflection.GeneratedProtocolMessageType('ResponseMessage', (_message.Message,), { - 'DESCRIPTOR' : _RESPONSEMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.ResponseMessage) - }) -_sym_db.RegisterMessage(ResponseMessage) - -ReputationMessage = _reflection.GeneratedProtocolMessageType('ReputationMessage', (_message.Message,), { - 'DESCRIPTOR' : _REPUTATIONMESSAGE, - '__module__' : 'nebula_pb2' - # @@protoc_insertion_point(class_scope:nebula.ReputationMessage) - }) -_sym_db.RegisterMessage(ReputationMessage) - +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "nebula_pb2", _globals) if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - _WRAPPER._serialized_start=25 - _WRAPPER._serialized_end=583 - _DISCOVERYMESSAGE._serialized_start=586 - _DISCOVERYMESSAGE._serialized_end=744 - _DISCOVERYMESSAGE_ACTION._serialized_start=692 - _DISCOVERYMESSAGE_ACTION._serialized_end=744 - _CONTROLMESSAGE._serialized_start=747 - _CONTROLMESSAGE._serialized_end=901 - _CONTROLMESSAGE_ACTION._serialized_start=825 - _CONTROLMESSAGE_ACTION._serialized_end=901 - _FEDERATIONMESSAGE._serialized_start=904 - _FEDERATIONMESSAGE._serialized_end=1109 - _FEDERATIONMESSAGE_ACTION._serialized_start=1009 - _FEDERATIONMESSAGE_ACTION._serialized_end=1109 - _MODELMESSAGE._serialized_start=1111 - _MODELMESSAGE._serialized_end=1176 - _CONNECTIONMESSAGE._serialized_start=1179 - _CONNECTIONMESSAGE._serialized_end=1322 - _CONNECTIONMESSAGE_ACTION._serialized_start=1250 - _CONNECTIONMESSAGE_ACTION._serialized_end=1322 - _DISCOVERMESSAGE._serialized_start=1324 - _DISCOVERMESSAGE._serialized_end=1438 - _DISCOVERMESSAGE_ACTION._serialized_start=1391 - _DISCOVERMESSAGE_ACTION._serialized_end=1438 - _OFFERMESSAGE._serialized_start=1441 - _OFFERMESSAGE._serialized_end=1647 - _OFFERMESSAGE_ACTION._serialized_start=1604 - _OFFERMESSAGE_ACTION._serialized_end=1647 - _LINKMESSAGE._serialized_start=1649 - _LINKMESSAGE._serialized_end=1768 - _LINKMESSAGE_ACTION._serialized_start=1723 - _LINKMESSAGE_ACTION._serialized_end=1768 - _RESPONSEMESSAGE._serialized_start=1770 - _RESPONSEMESSAGE._serialized_end=1805 - _REPUTATIONMESSAGE._serialized_start=1808 - _REPUTATIONMESSAGE._serialized_end=1945 - _REPUTATIONMESSAGE_ACTION._serialized_start=1926 - _REPUTATIONMESSAGE_ACTION._serialized_end=1945 + DESCRIPTOR._options = None + _globals["_WRAPPER"]._serialized_start = 25 + _globals["_WRAPPER"]._serialized_end = 583 + _globals["_DISCOVERYMESSAGE"]._serialized_start = 586 + _globals["_DISCOVERYMESSAGE"]._serialized_end = 744 + _globals["_DISCOVERYMESSAGE_ACTION"]._serialized_start = 692 + _globals["_DISCOVERYMESSAGE_ACTION"]._serialized_end = 744 + _globals["_CONTROLMESSAGE"]._serialized_start = 747 + _globals["_CONTROLMESSAGE"]._serialized_end = 901 + _globals["_CONTROLMESSAGE_ACTION"]._serialized_start = 825 + _globals["_CONTROLMESSAGE_ACTION"]._serialized_end = 901 + _globals["_FEDERATIONMESSAGE"]._serialized_start = 904 + _globals["_FEDERATIONMESSAGE"]._serialized_end = 1109 + _globals["_FEDERATIONMESSAGE_ACTION"]._serialized_start = 1009 + _globals["_FEDERATIONMESSAGE_ACTION"]._serialized_end = 1109 + _globals["_MODELMESSAGE"]._serialized_start = 1111 + _globals["_MODELMESSAGE"]._serialized_end = 1176 + _globals["_CONNECTIONMESSAGE"]._serialized_start = 1179 + _globals["_CONNECTIONMESSAGE"]._serialized_end = 1322 + _globals["_CONNECTIONMESSAGE_ACTION"]._serialized_start = 1250 + _globals["_CONNECTIONMESSAGE_ACTION"]._serialized_end = 1322 + _globals["_DISCOVERMESSAGE"]._serialized_start = 1325 + _globals["_DISCOVERMESSAGE"]._serialized_end = 1474 + _globals["_DISCOVERMESSAGE_ACTION"]._serialized_start = 1392 + _globals["_DISCOVERMESSAGE_ACTION"]._serialized_end = 1474 + _globals["_OFFERMESSAGE"]._serialized_start = 1477 + _globals["_OFFERMESSAGE"]._serialized_end = 1683 + _globals["_OFFERMESSAGE_ACTION"]._serialized_start = 1640 + _globals["_OFFERMESSAGE_ACTION"]._serialized_end = 1683 + _globals["_LINKMESSAGE"]._serialized_start = 1685 + _globals["_LINKMESSAGE"]._serialized_end = 1804 + _globals["_LINKMESSAGE_ACTION"]._serialized_start = 1759 + _globals["_LINKMESSAGE_ACTION"]._serialized_end = 1804 + _globals["_REPUTATIONMESSAGE"]._serialized_start = 1807 + _globals["_REPUTATIONMESSAGE"]._serialized_end = 1944 + _globals["_REPUTATIONMESSAGE_ACTION"]._serialized_start = 1925 + _globals["_REPUTATIONMESSAGE_ACTION"]._serialized_end = 1944 + _globals["_RESPONSEMESSAGE"]._serialized_start = 1946 + _globals["_RESPONSEMESSAGE"]._serialized_end = 1981 # @@protoc_insertion_point(module_scope) diff --git a/nebula/tests/__init__.py b/nebula/core/situationalawareness/__init__.py similarity index 100% rename from nebula/tests/__init__.py rename to nebula/core/situationalawareness/__init__.py diff --git a/nebula/core/situationalawareness/awareness/arbitrationpolicies/arbitrationpolicy.py b/nebula/core/situationalawareness/awareness/arbitrationpolicies/arbitrationpolicy.py new file mode 100644 index 000000000..3580483eb --- /dev/null +++ b/nebula/core/situationalawareness/awareness/arbitrationpolicies/arbitrationpolicy.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod + +from nebula.core.situationalawareness.awareness.sautils.sacommand import SACommand + + +class ArbitrationPolicy(ABC): + """ + Abstract base class defining the arbitration policy for resolving conflicts between SA commands. + + This class establishes the interface for implementing arbitration logic used in the + Situational Awareness module. It includes initialization and a tie-breaking mechanism + when two commands have the same priority or conflict. + + Methods: + - init(config): Initialize the arbitration policy with a configuration object. + - tie_break(sac1, sac2): Decide which command to keep when two conflict and have equal priority. + """ + + @abstractmethod + async def init(self, config): + """ + Initialize the arbitration policy with the provided configuration. + + Parameters: + config (Any): A configuration object or dictionary to set up internal parameters. + """ + raise NotImplementedError + + @abstractmethod + async def tie_break(self, sac1: SACommand, sac2: SACommand) -> bool: + """ + Resolve a conflict between two commands with equal priority. + + Parameters: + sac1 (SACommand): First command in conflict. + sac2 (SACommand): Second command in conflict. + + Returns: + bool: True if sac1 should be kept over sac2, False if sac2 is preferred. + """ + raise NotImplementedError + + +def factory_arbitration_policy(arbitatrion_policy, verbose) -> ArbitrationPolicy: + from nebula.core.situationalawareness.awareness.arbitrationpolicies.staticarbitrationpolicy import SAP + + options = { + "sap": SAP, # "Static Arbitatrion Policy" (SAP) -- default value + } + + cs = options.get(arbitatrion_policy, SAP) + return cs(verbose) diff --git a/nebula/core/situationalawareness/awareness/arbitrationpolicies/staticarbitrationpolicy.py b/nebula/core/situationalawareness/awareness/arbitrationpolicies/staticarbitrationpolicy.py new file mode 100644 index 000000000..c6815bd3f --- /dev/null +++ b/nebula/core/situationalawareness/awareness/arbitrationpolicies/staticarbitrationpolicy.py @@ -0,0 +1,48 @@ +import logging + +from nebula.core.situationalawareness.awareness.arbitrationpolicies.arbitrationpolicy import ArbitrationPolicy +from nebula.core.situationalawareness.awareness.sautils.sacommand import SACommand + + +class SAP(ArbitrationPolicy): # Static Arbitatrion Policy + def __init__(self, verbose): + self._verbose = verbose + # Define static weights for SA Agents from SA Components + self.agent_weights = {"SATraining": 1, "SANetwork": 2, "SAReputation": 3} + + async def init(self, config): + pass + + async def _get_agent_category(self, sa_command: SACommand) -> str: + """ + Extract agent category name. + Example: "SATraining_Agent1" β†’ "SATraining" + """ + full_name = await sa_command.get_owner() + return full_name.split("_")[0] if "_" in full_name else full_name + + async def tie_break(self, sac1: SACommand, sac2: SACommand) -> bool: + """ + Tie break conflcited SA Commands + """ + if self._verbose: + logging.info( + f"Tie break between ({await sac1.get_owner()}, {sac1.get_action().value}) & ({await sac2.get_owner()}, {sac2.get_action().value})" + ) + + async def get_weight(cmd): + category = await self._get_agent_category(cmd) + return self.agent_weights.get(category, 0) + + if await get_weight(sac1) > await get_weight(sac2): + if self._verbose: + logging.info( + f"Tie break resolved, SA Command choosen ({await sac1.get_owner()}, {sac1.get_action().value})" + ) + return True + else: + if self._verbose: + logging.info( + f"Tie break resolved, SA Command choosen ({await sac2.get_owner()}, {sac2.get_action().value})" + ) + return False diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/__init__.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/distanceneighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/distanceneighborpolicy.py new file mode 100644 index 000000000..3509fbea0 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/distanceneighborpolicy.py @@ -0,0 +1,182 @@ +import logging + +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import GPSEvent +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import NeighborPolicy +from nebula.core.utils.locker import Locker + + +class DistanceNeighborPolicy(NeighborPolicy): + # INFO: This value may change according to the needs of the federation + MAX_DISTANCE_THRESHOLD = 200 + + def __init__(self): + self.max_neighbors = None + self.nodes_known = set() + self.neighbors = set() + self.addr = None + self.neighbors_lock = Locker(name="neighbors_lock", async_lock=True) + self.nodes_known_lock = Locker(name="nodes_known_lock", async_lock=True) + self.nodes_distances: dict[str, tuple[float, tuple[float, float]]] = None + self.nodes_distances_lock = Locker("nodes_distances_lock", async_lock=True) + self._verbose = False + + async def set_config(self, config): + """ + Args: + config[0] -> list of self neighbors + config[1] -> list of nodes known on federation + config[2] -> self addr + config[3] -> stricted_topology + """ + logging.info("Initializing Distance Topology Neighbor Policy") + async with self.neighbors_lock: + self.neighbors = config[0] + for addr in config[1]: + self.nodes_known.add(addr) + self.addr + + await EventManager.get_instance().subscribe_addonevent(GPSEvent, self._udpate_distances) + + async def _udpate_distances(self, gpsevent: GPSEvent): + async with self.nodes_distances_lock: + distances = await gpsevent.get_event_data() + self.nodes_distances = distances + + async def need_more_neighbors(self): + async with self.neighbors_lock: + async with self.nodes_distances_lock: + if not self.nodes_distances: + return False + + closest_nodes: set[str] = { + nodo_id + for nodo_id, (distancia, _) in self.nodes_distances.items() + if distancia < self.MAX_DISTANCE_THRESHOLD + } + available_nodes = closest_nodes.difference(self.neighbors) + if self._verbose: + logging.info(f"Available neighbors based on distance: {available_nodes}") + return len(available_nodes) > 0 + + async def accept_connection(self, source, joining=False): + """ + return true if connection is accepted + """ + async with self.neighbors_lock: + ac = source not in self.neighbors + return ac + + async def meet_node(self, node): + """ + Update the list of nodes known on federation + """ + async with self.nodes_known_lock: + if node != self.addr: + if node not in self.nodes_known: + logging.info(f"Update nodes known | addr: {node}") + self.nodes_known.add(node) + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + if neighbors_only: + async with self.neighbors_lock: + no = self.neighbors.copy() + return no + + async with self.nodes_known_lock: + nk = self.nodes_known.copy() + if not neighbors_too: + async with self.neighbors_lock: + nk = self.nodes_known - self.neighbors + return nk + + async def forget_nodes(self, nodes, forget_all=False): + async with self.nodes_known_lock: + if forget_all: + self.nodes_known.clear() + else: + for node in nodes: + self.nodes_known.discard(node) + + async def get_actions(self): + """ + return list of actions to do in response to connection + - First list represents addrs argument to LinkMessage to connect to + - Second one represents the same but for disconnect from LinkMessage + """ + return [await self._connect_to(), await self._disconnect_from()] + + async def _disconnect_from(self): + return "" + + async def _connect_to(self): + ct = "" + async with self.neighbors_lock: + ct = " ".join(self.neighbors) + return ct + + async def update_neighbors(self, node, remove=False): + if node == self.addr: + return + async with self.neighbors_lock: + if remove: + try: + self.neighbors.remove(node) + if self._verbose: + logging.info(f"Remove neighbor | addr: {node}") + except KeyError: + pass + else: + self.neighbors.add(node) + if self._verbose: + logging.info(f"Add neighbor | addr: {node}") + + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + async with self.neighbors_lock: + async with self.nodes_distances_lock: + closest_nodes: set[str] = { + nodo_id + for nodo_id, (distancia, _) in self.nodes_distances.items() + if distancia < self.MAX_DISTANCE_THRESHOLD - 20 + } + if self._verbose: + logging.info(f"Closest nodes: {closest_nodes}, neighbors: {self.neighbors}") + available_nodes = closest_nodes.difference(self.neighbors) + if self._verbose: + logging.info(f"Available neighbors based on distance: {available_nodes}") + return available_nodes + + async def any_leftovers_neighbors(self): + distant_nodes = set() + async with self.neighbors_lock: + async with self.nodes_distances_lock: + if not self.nodes_distances: + return False + + distant_nodes: set[str] = { + nodo_id + for nodo_id, (distancia, _) in self.nodes_distances.items() + if distancia > self.MAX_DISTANCE_THRESHOLD + } + distant_nodes = self.neighbors.intersection(distant_nodes) + if self._verbose: + logging.info(f"Distant neighbors based on distance: {distant_nodes}") + return len(distant_nodes) > 0 + + async def get_neighbors_to_remove(self): + distant_nodes = set() + async with self.neighbors_lock: + async with self.nodes_distances_lock: + distant_nodes: set[str] = { + nodo_id + for nodo_id, (distancia, _) in self.nodes_distances.items() + if distancia > self.MAX_DISTANCE_THRESHOLD + } + distant_nodes = self.neighbors.intersection(distant_nodes) + if self._verbose: + logging.info(f"Remove neighbors based on distance: {distant_nodes}") + return distant_nodes + + def stricted_topology_status(stricted_topology: bool): + pass diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/fcneighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/fcneighborpolicy.py new file mode 100644 index 000000000..0737f3b0b --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/fcneighborpolicy.py @@ -0,0 +1,134 @@ +import logging + +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import NeighborPolicy +from nebula.core.utils.locker import Locker + + +class FCNeighborPolicy(NeighborPolicy): + def __init__(self): + self.max_neighbors = None + self.nodes_known = set() + self.neighbors = set() + self.addr = None + self.neighbors_lock = Locker(name="neighbors_lock") + self.nodes_known_lock = Locker(name="nodes_known_lock") + self._verbose = False + + async def need_more_neighbors(self): + """ + Fully connected network requires to be connected to all devices, therefore, + if there are more nodes known that self.neighbors, more neighbors are required + """ + self.neighbors_lock.acquire() + need_more = len(self.neighbors) < len(self.nodes_known) + self.neighbors_lock.release() + return need_more + + async def set_config(self, config): + """ + Args: + config[0] -> list of self neighbors + config[1] -> list of nodes known on federation + config[2] -> self addr + config[3] -> stricted_topology + """ + logging.info("Initializing Fully-Connected Topology Neighbor Policy") + self.neighbors_lock.acquire() + self.neighbors = config[0] + self.neighbors_lock.release() + for addr in config[1]: + self.nodes_known.add(addr) + self.addr + + async def accept_connection(self, source, joining=False): + """ + return true if connection is accepted + """ + self.neighbors_lock.acquire() + ac = source not in self.neighbors + self.neighbors_lock.release() + return ac + + async def meet_node(self, node): + """ + Update the list of nodes known on federation + """ + self.nodes_known_lock.acquire() + if node != self.addr: + if node not in self.nodes_known: + logging.info(f"Update nodes known | addr: {node}") + self.nodes_known.add(node) + self.nodes_known_lock.release() + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + if neighbors_only: + self.neighbors_lock.acquire() + no = self.neighbors.copy() + self.neighbors_lock.release() + return no + + self.nodes_known_lock.acquire() + nk = self.nodes_known.copy() + if not neighbors_too: + self.neighbors_lock.acquire() + nk = self.nodes_known - self.neighbors + self.neighbors_lock.release() + self.nodes_known_lock.release() + return nk + + async def forget_nodes(self, nodes, forget_all=False): + self.nodes_known_lock.acquire() + if forget_all: + self.nodes_known.clear() + else: + for node in nodes: + self.nodes_known.discard(node) + self.nodes_known_lock.release() + + async def get_actions(self): + """ + return list of actions to do in response to connection + - First list represents addrs argument to LinkMessage to connect to + - Second one represents the same but for disconnect from LinkMessage + """ + return [await self._connect_to(), await self._disconnect_from()] + + async def _disconnect_from(self): + return "" + + async def _connect_to(self): + ct = "" + self.neighbors_lock.acquire() + ct = " ".join(self.neighbors) + self.neighbors_lock.release() + return ct + + async def update_neighbors(self, node, remove=False): + if node == self.addr: + return + self.neighbors_lock.acquire() + if remove: + try: + self.neighbors.remove(node) + if self._verbose: + logging.info(f"Remove neighbor | addr: {node}") + except KeyError: + pass + else: + self.neighbors.add(node) + if self._verbose: + logging.info(f"Add neighbor | addr: {node}") + self.neighbors_lock.release() + + async def any_leftovers_neighbors(self): + return False + + async def get_neighbors_to_remove(self): + return set() + + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + return await self.get_nodes_known(neighbors_too=False) + + async def stricted_topology_status(stricted_topology: bool): + pass diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/idleneighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/idleneighborpolicy.py new file mode 100644 index 000000000..ac98032fb --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/idleneighborpolicy.py @@ -0,0 +1,134 @@ +import logging + +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import NeighborPolicy +from nebula.core.utils.locker import Locker + + +class IDLENeighborPolicy(NeighborPolicy): + def __init__(self): + self.max_neighbors = None + self.nodes_known = set() + self.neighbors = set() + self.addr = None + self.neighbors_lock = Locker(name="neighbors_lock") + self.nodes_known_lock = Locker(name="nodes_known_lock") + self._verbose = False + + async def need_more_neighbors(self): + """ + Fully connected network requires to be connected to all devices, therefore, + if there are more nodes known that self.neighbors, more neighbors are required + """ + self.neighbors_lock.acquire() + need_more = len(self.neighbors) <= 0 + self.neighbors_lock.release() + return need_more + + async def set_config(self, config): + """ + Args: + config[0] -> list of self neighbors + config[1] -> list of nodes known on federation + config[2] -> self addr + config[3] -> stricted_topology + """ + logging.info("Initializing Random Topology Neighbor Policy") + self.neighbors_lock.acquire() + self.neighbors = config[0] + self.neighbors_lock.release() + for addr in config[1]: + self.nodes_known.add(addr) + self.addr + + async def accept_connection(self, source, joining=False): + """ + return true if connection is accepted + """ + self.neighbors_lock.acquire() + ac = source not in self.neighbors + self.neighbors_lock.release() + return ac + + async def meet_node(self, node): + """ + Update the list of nodes known on federation + """ + self.nodes_known_lock.acquire() + if node != self.addr: + if node not in self.nodes_known: + logging.info(f"Update nodes known | addr: {node}") + self.nodes_known.add(node) + self.nodes_known_lock.release() + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + if neighbors_only: + self.neighbors_lock.acquire() + no = self.neighbors.copy() + self.neighbors_lock.release() + return no + + self.nodes_known_lock.acquire() + nk = self.nodes_known.copy() + if not neighbors_too: + self.neighbors_lock.acquire() + nk = self.nodes_known - self.neighbors + self.neighbors_lock.release() + self.nodes_known_lock.release() + return nk + + async def forget_nodes(self, nodes, forget_all=False): + self.nodes_known_lock.acquire() + if forget_all: + self.nodes_known.clear() + else: + for node in nodes: + self.nodes_known.discard(node) + self.nodes_known_lock.release() + + async def get_actions(self): + """ + return list of actions to do in response to connection + - First list represents addrs argument to LinkMessage to connect to + - Second one represents the same but for disconnect from LinkMessage + """ + return [await self._connect_to(), await self._disconnect_from()] + + async def _disconnect_from(self): + return "" + + async def _connect_to(self): + ct = "" + self.neighbors_lock.acquire() + ct = " ".join(self.neighbors) + self.neighbors_lock.release() + return ct + + async def update_neighbors(self, node, remove=False): + if node == self.addr: + return + self.neighbors_lock.acquire() + if remove: + try: + self.neighbors.remove(node) + if self._verbose: + logging.info(f"Remove neighbor | addr: {node}") + except KeyError: + pass + else: + self.neighbors.add(node) + if self._verbose: + logging.info(f"Add neighbor | addr: {node}") + self.neighbors_lock.release() + + async def any_leftovers_neighbors(self): + return False + + async def get_neighbors_to_remove(self): + return set() + + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + return await self.get_nodes_known(neighbors_too=False) + + async def stricted_topology_status(stricted_topology: bool): + pass diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/neighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/neighborpolicy.py new file mode 100644 index 000000000..980f3736d --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/neighborpolicy.py @@ -0,0 +1,127 @@ +from abc import ABC, abstractmethod + + +class NeighborPolicy(ABC): + @abstractmethod + async def set_config(self, config): + """Set internal configuration parameters for the neighbor policy, typically from a shared configuration object.""" + pass + + @abstractmethod + async def need_more_neighbors(self): + """Return True if the current node requires additional neighbors to fulfill its connectivity policy.""" + pass + + @abstractmethod + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + pass + + @abstractmethod + async def any_leftovers_neighbors(self): + """Return True if there are any neighbors that are no longer needed or should be replaced.""" + pass + + @abstractmethod + async def get_neighbors_to_remove(self): + """Return a list of neighbors that should be removed based on current policy constraints or evaluation.""" + pass + + @abstractmethod + async def accept_connection(self, source, joining=False): + """ + Determine whether to accept a connection request from a given node. + + Parameters: + source: The identifier of the node requesting the connection. + joining (bool): Whether this is an initial joining request. + + Returns: + bool: True if the connection is accepted, False otherwise. + """ + pass + + @abstractmethod + async def get_actions(self): + """Return a list of actions (e.g., add or remove neighbors) that should be executed to maintain the policy.""" + pass + + @abstractmethod + async def meet_node(self, node): + """ + Register the discovery or interaction with a new node. + + Parameters: + node: The node being encountered or added to internal memory. + """ + pass + + @abstractmethod + async def forget_nodes(self, nodes, forget_all=False): + """ + Remove the specified nodes from internal memory. + + Parameters: + nodes: A list of node identifiers to forget. + forget_all (bool): If True, forget all nodes. + """ + pass + + @abstractmethod + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + """ + Retrieve a list of nodes known by the current policy. + + Parameters: + neighbors_too (bool): If True, include current neighbors in the result. + neighbors_only (bool): If True, return only current neighbors. + + Returns: + list: A list of node identifiers. + """ + pass + + @abstractmethod + async def update_neighbors(self, node, remove=False): + """ + Add or remove a neighbor in the current neighbor set. + + Parameters: + node: The node to be added or removed. + remove (bool): If True, remove the node instead of adding. + """ + pass + + @abstractmethod + async def stricted_topology_status(stricted_topology: bool): + """ + Update the policy with the current strict topology status. + + Parameters: + stricted_topology (bool): True if the topology should be preserved. + """ + pass + + +def factory_NeighborPolicy(topology) -> NeighborPolicy: + from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.distanceneighborpolicy import ( + DistanceNeighborPolicy, + ) + from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.fcneighborpolicy import FCNeighborPolicy + from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.idleneighborpolicy import ( + IDLENeighborPolicy, + ) + from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.ringneighborpolicy import ( + RINGNeighborPolicy, + ) + + options = { + "random": IDLENeighborPolicy, # default value + "fully": FCNeighborPolicy, + "ring": RINGNeighborPolicy, + "star": IDLENeighborPolicy, + "distance": DistanceNeighborPolicy, + } + + cs = options.get(topology, IDLENeighborPolicy) + return cs() diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/ringneighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/ringneighborpolicy.py new file mode 100644 index 000000000..13ed6daf0 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/ringneighborpolicy.py @@ -0,0 +1,159 @@ +import asyncio +import logging +import random + +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import NeighborPolicy +from nebula.core.utils.locker import Locker + + +class RINGNeighborPolicy(NeighborPolicy): + RECENTLY_REMOVED_BAN_TIME = 20 + + def __init__(self): + self.max_neighbors = 2 + self.nodes_known = set() + self.neighbors = set() + self.neighbors_lock = Locker(name="neighbors_lock") + self.nodes_known_lock = Locker(name="nodes_known_lock") + self.addr = "" + self._excess_neighbors_removed = set() + self._excess_neighbors_removed_lock = Locker("excess_neighbors_removed_lock", async_lock=True) + self._verbose = False + + async def need_more_neighbors(self): + self.neighbors_lock.acquire() + need_more = len(self.neighbors) < self.max_neighbors + self.neighbors_lock.release() + return need_more + + async def set_config(self, config): + """ + Args: + config[0] -> list of self neighbors + config[1] -> list of nodes known on federation + config[2] -> self.addr + config[3] -> stricted_topology + """ + logging.info("Initializing Ring Topology Neighbor Policy") + self.neighbors_lock.acquire() + if self._verbose: + logging.info(f"neighbors: {config[0]}") + self.neighbors = config[0] + self.neighbors_lock.release() + for addr in config[1]: + self.nodes_known.add(addr) + self.addr = config[2] + + async def accept_connection(self, source, joining=False): + """ + return true if connection is accepted + """ + ac = False + if await self._is_recently_removed(source): + return ac + + with self.neighbors_lock: + if joining: + ac = source not in self.neighbors + else: + ac = not len(self.neighbors) >= self.max_neighbors + return ac + + async def meet_node(self, node): + self.nodes_known_lock.acquire() + if node != self.addr: + if node not in self.nodes_known: + logging.info(f"Update nodes known | addr: {node}") + self.nodes_known.add(node) + self.nodes_known_lock.release() + + async def forget_nodes(self, nodes, forget_all=False): + self.nodes_known_lock.acquire() + if forget_all: + self.nodes_known.clear() + else: + for node in nodes: + self.nodes_known.discard(node) + self.nodes_known_lock.release() + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + if neighbors_only: + self.neighbors_lock.acquire() + no = self.neighbors.copy() + self.neighbors_lock.release() + return no + + self.nodes_known_lock.acquire() + nk = self.nodes_known.copy() + if not neighbors_too: + self.neighbors_lock.acquire() + nk = self.nodes_known - self.neighbors + self.neighbors_lock.release() + self.nodes_known_lock.release() + return nk + + async def get_actions(self): + """ + return list of actions to do in response to connection + - First list represents addrs argument to LinkMessage to connect to + - Second one represents the same but for disconnect from LinkMessage + """ + self.neighbors_lock.acquire() + ct_actions = "" + df_actions = "" + if len(self.neighbors) == self.max_neighbors: + list_neighbors = list(self.neighbors) + index = random.randint(0, len(list_neighbors) - 1) + node = list_neighbors[index] + ct_actions = node # connect to + df_actions = node # disconnect from + self.neighbors_lock.release() + return [ct_actions, df_actions] + + async def update_neighbors(self, node, remove=False): + self.neighbors_lock.acquire() + if remove: + if node in self.neighbors: + self.neighbors.remove(node) + else: + self.neighbors.add(node) + self.neighbors_lock.release() + + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + return await self.get_nodes_known(neighbors_too=False) + + async def any_leftovers_neighbors(self): + self.neighbors_lock.acquire() + aln = len(self.neighbors) > self.max_neighbors + self.neighbors_lock.release() + return aln + + async def get_neighbors_to_remove(self): + neighbors = list() + self.neighbors_lock.acquire() + if self.neighbors: + neighbors = set(self.neighbors) + neighbors_to_remove = len(self.neighbors) - self.max_neighbors + neighbors = set(random.sample(list(neighbors), neighbors_to_remove)) + self.neighbors_lock.release() + await self._add_removed_ban(neighbors) + return neighbors + + async def stricted_topology_status(stricted_topology: bool): + pass + + async def _is_recently_removed(self, source): + async with self._excess_neighbors_removed_lock: + return source in self._excess_neighbors_removed + + async def _add_removed_ban(self, sources): + async with self._excess_neighbors_removed_lock: + for source in sources: + self._excess_neighbors_removed.add(source) + asyncio.create_task(self._clear_ban(source)) + + async def _clear_ban(self, source): + asyncio.sleep(self.RECENTLY_REMOVED_BAN_TIME) + async with self._excess_neighbors_removed_lock: + self._excess_neighbors_removed.discard(source) diff --git a/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/starneighborpolicy.py b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/starneighborpolicy.py new file mode 100644 index 000000000..896cb90b3 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/neighborpolicies/starneighborpolicy.py @@ -0,0 +1,95 @@ +import logging + +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import NeighborPolicy +from nebula.core.utils.locker import Locker + + +class STARNeighborPolicy(NeighborPolicy): + def __init__(self): + self.max_neighbors = 1 + self.nodes_known = set() + self.neighbors = set() + self.neighbors_lock = Locker(name="neighbors_lock") + self.nodes_known_lock = Locker(name="nodes_known_lock") + self.addr = "" + self._verbose = False + + async def need_more_neighbors(self): + self.neighbors_lock.acquire() + need_more = len(self.neighbors) < self.max_neighbors + self.neighbors_lock.release() + return need_more + + async def set_config(self, config): + """ + Args: + config[0] -> list of self neighbors, in this case, the star point + config[1] -> list of nodes known on federation + config[2] -> self.addr + config[3] -> stricted_topology + """ + self.neighbors_lock.acquire() + self.neighbors = config[0] + self.neighbors_lock.release() + for addr in config[1]: + self.nodes_known.add(addr) + self.addr = config[2] + + async def accept_connection(self, source, joining=False): + """ + return true if connection is accepted + """ + ac = joining + return ac + + async def meet_node(self, node): + self.nodes_known_lock.acquire() + if node != self.addr: + if node not in self.nodes_known: + logging.info(f"Update nodes known | addr: {node}") + self.nodes_known.add(node) + self.nodes_known_lock.release() + + async def forget_nodes(self, nodes, forget_all=False): + self.nodes_known_lock.acquire() + if forget_all: + self.nodes_known.clear() + else: + for node in nodes: + self.nodes_known.discard(node) + self.nodes_known_lock.release() + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + self.nodes_known_lock.acquire() + nk = self.nodes_known.copy() + if not neighbors_too: + self.neighbors_lock.acquire() + nk = self.nodes_known - self.neighbors + self.neighbors_lock.release() + self.nodes_known_lock.release() + return nk + + async def get_actions(self): + """ + return list of actions to do in response to connection + - First list represents addrs argument to LinkMessage to connect to + - Second one represents the same but for disconnect from LinkMessage + """ + self.neighbors_lock.acquire() + ct_actions = [] + df_actions = [] + if len(self.neighbors) < self.max_neighbors: + ct_actions.append(self.neighbors[0]) # connect to star point + df_actions.append(self.addr) # disconnect from me + self.neighbors_lock.release() + return [ct_actions, df_actions] + + async def update_neighbors(self, node, remove=False): + pass + + async def stricted_topology_status(stricted_topology: bool): + pass + + async def get_posible_neighbors(self): + """Return set of posible neighbors to connect to.""" + return await self.get_nodes_known(neighbors_too=False) diff --git a/nebula/core/situationalawareness/awareness/sanetwork/sanetwork.py b/nebula/core/situationalawareness/awareness/sanetwork/sanetwork.py new file mode 100644 index 000000000..b09904ba9 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sanetwork/sanetwork.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +from nebula.addons.functions import print_msg_box +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import ( + BeaconRecievedEvent, + ExperimentFinishEvent, + NodeFoundEvent, + RoundEndEvent, + UpdateNeighborEvent, +) +from nebula.core.network.communications import CommunicationsManager +from nebula.core.situationalawareness.awareness.sanetwork.neighborpolicies.neighborpolicy import factory_NeighborPolicy +from nebula.core.situationalawareness.awareness.sareasoner import SAMComponent +from nebula.core.situationalawareness.awareness.sautils.sacommand import ( + SACommand, + SACommandAction, + SACommandPRIO, + SACommandState, + factory_sa_command, +) +from nebula.core.situationalawareness.awareness.sautils.samoduleagent import SAModuleAgent +from nebula.core.situationalawareness.awareness.suggestionbuffer import SuggestionBuffer +from nebula.core.utils.locker import Locker + +if TYPE_CHECKING: + from nebula.core.situationalawareness.awareness.sareasoner import SAReasoner + +RESTRUCTURE_COOLDOWN = 1 # 5 + + +class SANetwork(SAMComponent): + NEIGHBOR_VERIFICATION_TIMEOUT = 30 + + def __init__(self, config): + self._neighbor_policy = config["neighbor_policy"] # topology + self._neighbor_policy = self._neighbor_policy.lower() + self._strict_topology = config["strict_topology"] # strict_topology + print_msg_box( + msg=f"Starting Network SA\nNeighbor Policy: {self._neighbor_policy}\nStrict: {self._strict_topology}", + indent=2, + title="Network SA module", + ) + self._sar = config["sar"] # sar + self._addr = config["addr"] # addr + self._neighbor_policy = factory_NeighborPolicy(self._neighbor_policy) + self._restructure_process_lock = Locker(name="restructure_process_lock") + self._restructure_cooldown = 0 + self._verbose = config["verbose"] # verbose + self._cm = CommunicationsManager.get_instance() + self._sa_network_agent = SANetworkAgent(self) + + @property + def sar(self) -> SAReasoner: + """SA Reasoner""" + return self._sar + + @property + def cm(self): + return self._cm + + @property + def np(self): + """Neighbor Policy""" + return self._neighbor_policy + + @property + def sana(self): + """SA Network Agent""" + return self._sa_network_agent + + async def init(self): + if not self.sar.is_additional_participant(): + logging.info("Deploying External Connection Service") + await self.cm.start_external_connection_service() + await EventManager.get_instance().subscribe_node_event(BeaconRecievedEvent, self.beacon_received) + await EventManager.get_instance().subscribe_node_event(ExperimentFinishEvent, self.experiment_finish) + await self.cm.start_beacon() + else: + logging.info("Deploying External Connection Service | No running") + await self.cm.start_external_connection_service(run_service=False) + + logging.info("Building neighbor policy configuration..") + await self.np.set_config([ + await self.cm.get_addrs_current_connections(only_direct=True, myself=False), + await self.cm.get_addrs_current_connections(only_direct=False, only_undirected=False, myself=False), + self._addr, + self._strict_topology, + ]) + + await EventManager.get_instance().subscribe_node_event(NodeFoundEvent, self._process_node_found_event) + await EventManager.get_instance().subscribe_node_event(UpdateNeighborEvent, self._process_update_neighbor_event) + await self.sana.register_sa_agent() + + async def sa_component_actions(self): + logging.info("SA Network evaluating current scenario") + await self._check_external_connection_service_status() + await self._analize_topology_robustness() + + """ ############################### + # NEIGHBOR POLICY # + ############################### + """ + + async def _process_node_found_event(self, nfe: NodeFoundEvent): + node_addr = await nfe.get_event_data() + await self.np.meet_node(node_addr) + + async def _process_update_neighbor_event(self, une: UpdateNeighborEvent): + node_addr, removed = await une.get_event_data() + if self._verbose: + logging.info(f"Processing Update Neighbor Event, node addr: {node_addr}, remove: {removed}") + await self.np.update_neighbors(node_addr, removed) + + async def meet_node(self, node): + if node != self._addr: + await self.np.meet_node(node) + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + return await self.np.get_nodes_known(neighbors_too, neighbors_only) + + async def neighbors_left(self): + return len(await self.cm.get_addrs_current_connections(only_direct=True, myself=False)) > 0 + + async def accept_connection(self, source, joining=False): + accepted = await self.np.accept_connection(source, joining) + return accepted + + async def need_more_neighbors(self): + return await self.np.need_more_neighbors() + + async def get_actions(self): + return await self.np.get_actions() + + """ ############################### + # EXTERNAL CONNECTION SERVICE # + ############################### + """ + + async def _check_external_connection_service_status(self): + if not await self.cm.is_external_connection_service_running(): + logging.info("πŸ”„ External Service not running | Starting service...") + await self.cm.init_external_connection_service() + await EventManager.get_instance().subscribe_node_event(BeaconRecievedEvent, self.beacon_received) + await self.cm.start_beacon() + + async def experiment_finish(self): + await self.cm.stop_external_connection_service() + + async def beacon_received(self, beacon_recieved_event: BeaconRecievedEvent): + addr, geoloc = await beacon_recieved_event.get_event_data() + latitude, longitude = geoloc + nfe = NodeFoundEvent(addr) + asyncio.create_task(EventManager.get_instance().publish_node_event(nfe)) + + """ ############################### + # REESTRUCTURE TOPOLOGY # + ############################### + """ + + def _update_restructure_cooldown(self): + if self._restructure_cooldown > 0: + self._restructure_cooldown = (self._restructure_cooldown + 1) % RESTRUCTURE_COOLDOWN + + def _restructure_available(self): + if self._restructure_cooldown: + if self._verbose: + logging.info("Reestructure on cooldown") + return self._restructure_cooldown == 0 + + def get_restructure_process_lock(self): + return self._restructure_process_lock + + async def _analize_topology_robustness(self): + # TODO update the way of checking + logging.info("πŸ”„ Analizing node network robustness...") + if not self._restructure_process_lock.locked(): + if not await self.neighbors_left(): + if self._verbose: + logging.info("No Neighbors left | reconnecting with Federation") + await self.sana.create_and_suggest_action( + SACommandAction.RECONNECT, self.reconnect_to_federation, False, None + ) + elif await self.np.need_more_neighbors() and self._restructure_available(): + if self._verbose: + logging.info("Suggesting to Remove neighbors according to policy...") + if await self.np.any_leftovers_neighbors(): + nodes_to_remove = await self.np.get_neighbors_to_remove() + await self.sana.create_and_suggest_action( + SACommandAction.DISCONNECT, self.cm.disconnect, True, nodes_to_remove + ) + if self._verbose: + logging.info("Insufficient Robustness | Upgrading robustness | Searching for more connections") + self._update_restructure_cooldown() + possible_neighbors = await self.np.get_posible_neighbors() + possible_neighbors = await self.cm.apply_restrictions(possible_neighbors) + if not possible_neighbors: + if self._verbose: + logging.info("All possible neighbors using nodes known are restricted...") + else: + pass + await self.sana.create_and_suggest_action( + SACommandAction.SEARCH_CONNECTIONS, self.upgrade_connection_robustness, False, possible_neighbors + ) + elif await self.np.any_leftovers_neighbors(): + nodes_to_remove = await self.np.get_neighbors_to_remove() + if self._verbose: + logging.info(f"Excess neighbors | removing: {list(nodes_to_remove)}") + await self.sana.create_and_suggest_action( + SACommandAction.DISCONNECT, self.cm.disconnect, False, nodes_to_remove + ) + else: + if self._verbose: + logging.info("Sufficient Robustness | no actions required") + await self.sana.create_and_suggest_action( + SACommandAction.MAINTAIN_CONNECTIONS, + self.cm.clear_unused_undirect_connections, + more_suggestions=False, + ) + else: + if self._verbose: + logging.info("❗️ Reestructure/Reconnecting process already running...") + await self.sana.create_and_suggest_action(SACommandAction.IDLE, more_suggestions=False) + + async def reconnect_to_federation(self): + logging.info("Going to reconnect with federation...") + self._restructure_process_lock.acquire() + await self.cm.clear_restrictions() + # If we got some refs, try to reconnect to them + if len(await self.np.get_nodes_known()) > 0: + if self._verbose: + logging.info("Reconnecting | Addrs availables") + await self.sar.sad.start_late_connection_process( + connected=False, msg_type="discover_nodes", addrs_known=await self.np.get_nodes_known() + ) + else: + if self._verbose: + logging.info("Reconnecting | NO Addrs availables") + await self.sar.sad.start_late_connection_process(connected=False, msg_type="discover_nodes") + self._restructure_process_lock.release() + + async def upgrade_connection_robustness(self, possible_neighbors): + self._restructure_process_lock.acquire() + # If we got some refs, try to connect to them + if possible_neighbors and len(possible_neighbors) > 0: + if self._verbose: + logging.info(f"Reestructuring | Addrs availables | addr list: {possible_neighbors}") + await self.sar.sad.start_late_connection_process( + connected=True, msg_type="discover_nodes", addrs_known=possible_neighbors + ) + else: + if self._verbose: + logging.info("Reestructuring | NO Addrs availables") + await self.sar.sad.start_late_connection_process(connected=True, msg_type="discover_nodes") + self._restructure_process_lock.release() + + async def stop_connections_with_federation(self): + await asyncio.sleep(10) + logging.info("### DISCONNECTING FROM FEDERATON ###") + neighbors = await self.np.get_nodes_known(neighbors_only=True) + for n in neighbors: + await self.cm.add_to_blacklist(n) + for n in neighbors: + await self.cm.disconnect(n, mutual_disconnection=False, forced=True) + + async def verify_neighbors_stablished(self, nodes: set): + if not nodes: + return + + await asyncio.sleep(self.NEIGHBOR_VERIFICATION_TIMEOUT) + logging.info("Verifyng all connections were stablished") + nodes_to_forget = nodes.copy() + neighbors = await self.np.get_nodes_known(neighbors_only=True) + if neighbors: + nodes_to_forget.difference_update(neighbors) + logging.info(f"Connections dont stablished: {nodes_to_forget}") + self.forget_nodes(nodes_to_forget) + + async def forget_nodes(self, nodes_to_forget): + await self.np.forget_nodes(nodes_to_forget) + + """ ############################### + # SA NETWORK AGENT # + ############################### + """ + + +class SANetworkAgent(SAModuleAgent): + def __init__(self, sanetwork: SANetwork): + self._san = sanetwork + + async def get_agent(self) -> str: + return "SANetwork_MainNetworkAgent" + + async def register_sa_agent(self): + await SuggestionBuffer.get_instance().register_event_agents(RoundEndEvent, self) + + async def suggest_action(self, sac: SACommand): + await SuggestionBuffer.get_instance().register_suggestion(RoundEndEvent, self, sac) + + async def notify_all_suggestions_done(self, event_type): + await SuggestionBuffer.get_instance().notify_all_suggestions_done_for_agent(self, event_type) + + async def create_and_suggest_action( + self, saca: SACommandAction, function: Callable = None, more_suggestions=False, *args + ): + sac = None + if saca == SACommandAction.MAINTAIN_CONNECTIONS: + sac = factory_sa_command( + "connectivity", SACommandAction.MAINTAIN_CONNECTIONS, self, "", SACommandPRIO.MEDIUM, False, function + ) + await self.suggest_action(sac) + await self.notify_all_suggestions_done(RoundEndEvent) + elif saca == SACommandAction.SEARCH_CONNECTIONS: + sac = factory_sa_command( + "connectivity", + SACommandAction.SEARCH_CONNECTIONS, + self, + "", + SACommandPRIO.MEDIUM, + True, + function, + *args, + ) + await self.suggest_action(sac) + if not more_suggestions: + await self.notify_all_suggestions_done(RoundEndEvent) + sa_command_state = await sac.get_state_future() # By using 'await' we get future.set_result() + if sa_command_state == SACommandState.EXECUTED: + (nodes_to_forget,) = args + asyncio.create_task(self._san.verify_neighbors_stablished(nodes_to_forget)) + elif saca == SACommandAction.RECONNECT: + sac = factory_sa_command( + "connectivity", SACommandAction.RECONNECT, self, "", SACommandPRIO.HIGH, True, function + ) + await self.suggest_action(sac) + if not more_suggestions: + await self.notify_all_suggestions_done(RoundEndEvent) + elif saca == SACommandAction.DISCONNECT: + nodes = args[0] if isinstance(args[0], set) else set(args) + for node in nodes: + sac = factory_sa_command( + "connectivity", + SACommandAction.DISCONNECT, + self, + node, + SACommandPRIO.HIGH, + True, + function, + node, + True, + ) + # TODO Check executed state to ensure node is removed + await self.suggest_action(sac) + if not more_suggestions: + await self.notify_all_suggestions_done(RoundEndEvent) + elif saca == SACommandAction.IDLE: + sac = factory_sa_command( + "connectivity", SACommandAction.IDLE, self, "", SACommandPRIO.LOW, False, function, None + ) + await self.suggest_action(sac) + if not more_suggestions: + await self.notify_all_suggestions_done(RoundEndEvent) diff --git a/nebula/core/situationalawareness/awareness/sareasoner.py b/nebula/core/situationalawareness/awareness/sareasoner.py new file mode 100644 index 000000000..23c901d50 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sareasoner.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import asyncio +import copy +import importlib.util +import logging +import os +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from nebula.addons.functions import print_msg_box +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import AggregationEvent, RoundEndEvent +from nebula.core.network.communications import CommunicationsManager +from nebula.core.situationalawareness.awareness.arbitrationpolicies.arbitrationpolicy import factory_arbitration_policy +from nebula.core.situationalawareness.awareness.sautils.sacommand import SACommand +from nebula.core.situationalawareness.awareness.sautils.sasystemmonitor import SystemMonitor +from nebula.core.situationalawareness.awareness.suggestionbuffer import SuggestionBuffer +from nebula.core.situationalawareness.situationalawareness import ISADiscovery, ISAReasoner +from nebula.core.utils.locker import Locker + +if TYPE_CHECKING: + from nebula.core.situationalawareness.awareness.sanetwork.sanetwork import SANetwork + + +class SAMComponent(ABC): + """ + Abstract base class representing a Situational Awareness Module Component (SAMComponent). + + Each SAMComponent is responsible for analyzing specific aspects of the system's state and + proposing relevant actions. These components act as internal reasoning units within the + SAReasoner and contribute suggestions to the command arbitration process. + + Methods: + - init(): Initialize internal state and resources required by the component. + - sa_component_actions(): Generate and return actions based on local analysis. + """ + + @abstractmethod + async def init(self): + """ + Initialize the SAMComponent. + + This method should prepare any internal state, models, or resources required + before the component starts analyzing and proposing actions. + """ + raise NotImplementedError + + @abstractmethod + async def sa_component_actions(self): + """ + Analyze system state and generate a list of SACommand suggestions. + It uses the SuggestionBuffer to send a list of SACommands. + """ + raise NotImplementedError + + +class SAReasoner(ISAReasoner): + """ + Core implementation of the Situational Awareness Reasoner (SAReasoner). + + This class coordinates the lifecycle and interactions of all internal components + in the SA module, including SAMComponents (reasoning units), the suggestion buffer, + and the arbitration policy. It is responsible for: + + - Initializing and managing all registered SAMComponents. + - Collecting suggestions from each component in response to events. + - Registering and notifying the SuggestionBuffer of suggestions. + - Triggering arbitration when multiple conflicting commands are proposed. + - Interfacing with the wider system through the ISAReasoner interface. + + This class acts as the central controller for decision-making based on local + or global awareness in distributed systems. + """ + + MODULE_PATH = "nebula/nebula/core/situationalawareness/awareness" + + def __init__( + self, + config, + ): + print_msg_box( + msg="Starting Situational Awareness Reasoner module...", + indent=2, + title="SA Reasoner", + ) + logging.info("🌐 Initializing SAReasoner") + self._config = copy.deepcopy(config.participant) + self._addr = config.participant["network_args"]["addr"] + self._topology = config.participant["mobility_args"]["topology_type"] + self._situational_awareness_network = None + self._situational_awareness_training = None + self._restructure_process_lock = Locker(name="restructure_process_lock") + self._restructure_cooldown = 0 + self._arbitrator_notification = asyncio.Event() + self._suggestion_buffer = SuggestionBuffer(self._arbitrator_notification, verbose=True) + self._communciation_manager = CommunicationsManager.get_instance() + self._sys_monitor = SystemMonitor() + arb_pol = config.participant["situational_awareness"]["sa_reasoner"]["arbitration_policy"] + self._arbitatrion_policy = factory_arbitration_policy(arb_pol, True) + self._sa_components: dict[str, SAMComponent] = {} + self._sa_discovery: ISADiscovery = None + self._verbose = config.participant["situational_awareness"]["sa_reasoner"]["verbose"] + + @property + def san(self) -> SANetwork: + """Situational Awareness Network""" + return self._situational_awareness_network + + @property + def cm(self): + return self._communciation_manager + + @property + def sb(self): + """Suggestion Buffer""" + return self._suggestion_buffer + + @property + def ab(self): + """Arbitatrion Policy""" + return self._arbitatrion_policy + + @property + def sad(self) -> ISADiscovery: + """SA Discovery""" + return self._sa_discovery + + async def init(self, sa_discovery): + self._sa_discovery: ISADiscovery = sa_discovery + await self._loading_sa_components() + await EventManager.get_instance().subscribe_node_event(RoundEndEvent, self._process_round_end_event) + await EventManager.get_instance().subscribe_node_event(AggregationEvent, self._process_aggregation_event) + + def is_additional_participant(self): + return self._config["mobility_args"]["additional_node"]["status"] + + """ ############################### + # REESTRUCTURE TOPOLOGY # + ############################### + """ + + def get_restructure_process_lock(self): + return self.san.get_restructure_process_lock() + + """ ############################### + # SA NETWORK # + ############################### + """ + + async def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + return await self.san.get_nodes_known(neighbors_too, neighbors_only) + + async def accept_connection(self, source, joining=False): + return await self.san.accept_connection(source, joining) + + async def get_actions(self): + return await self.san.get_actions() + + """ ############################### + # ARBITRATION # + ############################### + """ + + async def _process_round_end_event(self, ree: RoundEndEvent): + logging.info("πŸ”„ Arbitration | Round End Event...") + for sa_comp in self._sa_components.values(): + asyncio.create_task(sa_comp.sa_component_actions()) + valid_commands = await self._arbitatrion_suggestions(RoundEndEvent) + + # Execute SACommand selected + if self._verbose: + logging.info(f"Going to execute {len(valid_commands)} SACommands") + for cmd in valid_commands: + if cmd.is_parallelizable(): + if self._verbose: + logging.info( + f"going to execute parallelizable action: {cmd.get_action()} made by: {await cmd.get_owner()}" + ) + asyncio.create_task(cmd.execute()) + else: + if self._verbose: + logging.info(f"going to execute action: {cmd.get_action()} made by: {await cmd.get_owner()}") + await cmd.execute() + + async def _process_aggregation_event(self, age: AggregationEvent): + logging.info("πŸ”„ Arbitration | Aggregation Event...") + aggregation_command = await self._arbitatrion_suggestions(AggregationEvent) + if len(aggregation_command): + if self._verbose: + logging.info( + f"Aggregation event resolved. SA Agente that suggest action: {await aggregation_command[0].get_owner}" + ) + final_updates = await aggregation_command[0].execute() + age.update_updates(final_updates) + + async def _arbitatrion_suggestions(self, event_type): + """ + Perform arbitration over a set of agent suggestions for a given event type. + + This method waits for all suggestions to be submitted, detects and resolves + conflicts based on command priorities and optional tie-breaking, and + returns a list of valid, non-conflicting commands. + + Parameters: + event_type: The identifier or type of the event for which suggestions are being arbitrated. + + Returns: + list[SACommand]: A list of validated and conflict-free commands after arbitration. + """ + if self._verbose: + logging.info("Waiting for all suggestions done") + await self.sb.set_event_waited(event_type) + await self._arbitrator_notification.wait() + if self._verbose: + logging.info("waiting released") + suggestions = await self.sb.get_suggestions(event_type) + self._arbitrator_notification.clear() + if not len(suggestions): + if self._verbose: + logging.info("No suggestions for this event | Arbitatrion not required") + return [] + + if self._verbose: + logging.info(f"Starting arbitatrion | Number of suggestions received: {len(suggestions)}") + + valid_commands: list[SACommand] = [] + + for agent, cmd in suggestions: + has_conflict = False + to_remove: list[SACommand] = [] + + for other in valid_commands: + if await cmd.conflicts_with(other): + if self._verbose: + logging.info( + f"Conflict detected between -- {await cmd.get_owner()} and {await other.get_owner()} --" + ) + if self._verbose: + logging.info(f"Action in conflict ({cmd.get_action()}, {other.get_action()})") + if cmd.got_higher_priority_than(other.get_prio()): + to_remove.append(other) + elif cmd.get_prio() == other.get_prio(): + if await self.ab.tie_break(cmd, other): + to_remove.append(other) + else: + has_conflict = True + break + else: + has_conflict = True + break + + if not has_conflict: + for r in to_remove: + await r.discard_command() + valid_commands.remove(r) + valid_commands.append(cmd) + + logging.info("Arbitatrion finished") + return valid_commands + + """ ############################### + # SA COMPONENT LOADING # + ############################### + """ + + def _to_pascal_case(self, name: str) -> str: + """Converts a snake_case or compact lowercase name into PascalCase with 'SA' prefix.""" + if name.startswith("sa_"): + name = name[3:] # remove 'sa_' prefix + elif name.startswith("sa"): + name = name[2:] # remove 'sa' prefix + parts = name.split("_") if "_" in name else [name] + return "SA" + "".join(part.capitalize() for part in parts) + + async def _loading_sa_components(self): + """Dynamically loads the SA Components defined in the JSON configuration.""" + self._load_minimal_requirement_config() + sa_section = self._config["situational_awareness"]["sa_reasoner"] + components: dict = sa_section["sar_components"] + + for component_name, is_enabled in components.items(): + if is_enabled: + component_config = sa_section[component_name] + component_name = component_name.replace("_", "") + class_name = self._to_pascal_case(component_name) + module_path = os.path.join(self.MODULE_PATH, component_name) + module_file = os.path.join(module_path, f"{component_name}.py") + + if os.path.exists(module_file): + module = await self._load_component(class_name, module_file, component_config) + if module: + self._sa_components[component_name] = module + else: + logging.error(f"⚠️ SA Component {component_name} not found on {module_file}") + + await self._set_minimal_requirements() + await self._initialize_sa_components() + + async def _load_component(self, class_name, component_file, config): + """Loads a SA Component dynamically and initializes it with its configuration.""" + spec = importlib.util.spec_from_file_location(class_name, component_file) + if spec and spec.loader: + component = importlib.util.module_from_spec(spec) + spec.loader.exec_module(component) + if hasattr(component, class_name): # Verify if class exists + return getattr(component, class_name)(config) # Create and instance using component config + else: + logging.error(f"⚠️ Cannot create {class_name} SA Component, class not found on {component_file}") + return None + + async def _initialize_sa_components(self): + if self._sa_components: + for sacomp in self._sa_components.values(): + await sacomp.init() + + def _load_minimal_requirement_config(self): + self._config["situational_awareness"]["sa_reasoner"]["sa_network"]["addr"] = self._addr + self._config["situational_awareness"]["sa_reasoner"]["sa_network"]["sar"] = self + self._config["situational_awareness"]["sa_reasoner"]["sa_network"]["strict_topology"] = self._config[ + "situational_awareness" + ]["strict_topology"] + + async def _set_minimal_requirements(self): + if self._sa_components: + self._situational_awareness_network = self._sa_components["sanetwork"] + else: + raise ValueError("SA Network not found") diff --git a/nebula/core/situationalawareness/awareness/sautils/sacommand.py b/nebula/core/situationalawareness/awareness/sautils/sacommand.py new file mode 100644 index 000000000..59f1255db --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sautils/sacommand.py @@ -0,0 +1,243 @@ +import asyncio +from abc import abstractmethod +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nebula.core.situationalawareness.awareness.sautils.samoduleagent import SAModuleAgent + + +class SACommandType(Enum): + CONNECTIVITY = "Connectivity" + AGGREGATION = "Aggregation" + + +# TODO make differents parts +class SACommandAction(Enum): + IDLE = "idle" + DISCONNECT = "disconnect" + RECONNECT = "reconnect" + SEARCH_CONNECTIONS = "search_connections" + MAINTAIN_CONNECTIONS = "maintain_connections" + ADJUST_WEIGHT = "adjust_weight" + DISCARD_UPDATE = "discard_update" + + +class SACommandPRIO(Enum): + CRITICAL = 20 + HIGH = 10 + MEDIUM = 5 + LOW = 3 + MAINTENANCE = 1 + + +class SACommandState(Enum): + PENDING = "pending" + DISCARDED = "discarded" + EXECUTED = "executed" + + """ ############################### + # SA COMMAND CLASS # + ############################### + """ + + +class SACommand: + """ + Base class for Situational Awareness (SA) module commands. + + This class defines the core structure and behavior of commands that can be + issued by SA agents. Each command has an associated type, action, target, + priority, and execution state. Commands may also declare whether they can be + executed in parallel. Subclasses must implement the actual logic for execution + and conflict detection. + + Attributes: + command_type (SACommandType): Type of the command (e.g., parameter update, structural change). + action (SACommandAction): Specific action the command performs. + owner (SAModuleAgent): Reference to the module or agent that issued the command. + target (Any): Target of the command (e.g., node, parameter name). + priority (SACommandPRIO): Priority level of the command. + parallelizable (bool): Indicates whether the command can be run concurrently. + _state (SACommandState): Internal state of the command (e.g., PENDING, DISCARDED). + _state_future (asyncio.Future): Future that resolves when the command changes state. + """ + + def __init__( + self, + command_type: SACommandType, + action: SACommandAction, + owner: "SAModuleAgent", + target, + priority: SACommandPRIO = SACommandPRIO.MEDIUM, + parallelizable=False, + ): + self._command_type = command_type + self._action = action + self._owner = owner + self._target = target # Could be a node, parameter, etc. + self._priority = priority + self._parallelizable = parallelizable + self._state = SACommandState.PENDING + self._state_future = asyncio.get_event_loop().create_future() + + @abstractmethod + async def execute(self): + """ + Execute the command's action on the specified target. + + This method must be implemented by subclasses to define the actual logic + of how the command affects the system. It may involve sending messages, + modifying local or global state, or interacting with external components. + """ + raise NotImplementedError + + @abstractmethod + async def conflicts_with(self, other: "SACommand") -> bool: + """ + Determine whether this command conflicts with another command. + + This method must be implemented by subclasses to define conflict logic, + e.g., whether two commands target the same resource in incompatible ways. + Used during arbitration to resolve simultaneous command suggestions. + + Parameters: + other (SACommand): Another command instance to check for conflicts. + + Returns: + bool: True if there is a conflict, False otherwise. + """ + raise NotImplementedError + + async def discard_command(self): + await self._update_command_state(SACommandState.DISCARDED) + + def got_higher_priority_than(self, other_prio: SACommandPRIO): + return self._priority.value > other_prio.value + + def get_prio(self): + return self._priority + + async def get_owner(self): + return await self._owner.get_agent() + + def get_action(self) -> SACommandAction: + return self._action + + async def _update_command_state(self, sacs: SACommandState): + self._state = sacs + if not self._state_future.done(): + self._state_future.set_result(sacs) + + def get_state_future(self) -> asyncio.Future: + return self._state_future + + def is_parallelizable(self): + return self._parallelizable + + def __repr__(self): + return ( + f"{self.__class__.__name__}(Type={self._command_type.value}, " + f"Action={self._action.value}, Target={self._target}, Priority={self._priority.value})" + ) + + """ ############################### + # SA COMMAND SUBCLASS # + ############################### + """ + + +class ConnectivityCommand(SACommand): + """Commands related to connectivity.""" + + def __init__( + self, + action: SACommandAction, + owner: "SAModuleAgent", + target: str, + priority: SACommandPRIO = SACommandPRIO.MEDIUM, + parallelizable=False, + action_function=None, + *args, + ): + super().__init__(SACommandType.CONNECTIVITY, action, owner, target, priority, parallelizable) + self._action_function = action_function + self._args = args + + async def execute(self): + """Executes the assigned action function with the given parameters.""" + await self._update_command_state(SACommandState.EXECUTED) + if self._action_function: + if asyncio.iscoroutinefunction(self._action_function): + await self._action_function(*self._args) + else: + self._action_function(*self._args) + + async def conflicts_with(self, other: "ConnectivityCommand") -> bool: + """Determines if two commands conflict with each other.""" + if await self._owner.get_agent() == await other._owner.get_agent(): + return False + + if self._target == other._target: + conflict_pairs = [ + {SACommandAction.DISCONNECT, SACommandAction.DISCONNECT}, + ] + return {self._action, other._action} in conflict_pairs + else: + conflict_pairs = [ + {SACommandAction.DISCONNECT, SACommandAction.RECONNECT}, + {SACommandAction.DISCONNECT, SACommandAction.MAINTAIN_CONNECTIONS}, + {SACommandAction.DISCONNECT, SACommandAction.SEARCH_CONNECTIONS}, + ] + return {self._action, other._action} in conflict_pairs + + +class AggregationCommand(SACommand): + """Commands related to data aggregation.""" + + def __init__( + self, + action: SACommandAction, + owner: "SAModuleAgent", + target: dict, + priority: SACommandPRIO = SACommandPRIO.MEDIUM, + parallelizable=False, + ): + super().__init__(SACommandType.CONNECTIVITY, action, owner, target, priority, parallelizable) + + async def execute(self): + await self._update_command_state(SACommandState.EXECUTED) + return self._target + + async def conflicts_with(self, other: "AggregationCommand") -> bool: + """Determines if two commands conflict with each other.""" + topologic_conflict = False + weight_conflict = False + + if set(self._target.keys()) != set(other._target.keys()): + topologic_conflict = True + + weight_conflict = any( + abs(self._target[node][1] - other._target[node][1]) > 0 + for node in self._target.keys() + if node in other._target.keys() + ) + + return weight_conflict and topologic_conflict + + """ ############################### + # SA COMMAND FACTORY # + ############################### + """ + + +def factory_sa_command(sacommand_type, *config) -> SACommand: + options = { + "connectivity": ConnectivityCommand, + "aggregation": AggregationCommand, + } + + cs = options.get(sacommand_type) + if cs is None: + raise ValueError(f"Unknown SACommand type: {sacommand_type}") + return cs(*config) diff --git a/nebula/core/situationalawareness/awareness/sautils/samoduleagent.py b/nebula/core/situationalawareness/awareness/sautils/samoduleagent.py new file mode 100644 index 000000000..3a1b09683 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sautils/samoduleagent.py @@ -0,0 +1,58 @@ +from abc import ABC, abstractmethod + +from nebula.core.situationalawareness.awareness.sautils.sacommand import SACommand + + +class SAModuleAgent(ABC): + """ + Abstract base class representing a Situational Awareness (SA) module agent. + + This interface defines the essential methods that any SA agent must implement + to participate in the suggestion and arbitration pipeline. Agents are responsible + for registering themselves, suggesting actions in the form of commands, and + notifying when all suggestions related to an event are complete. + + Methods: + - get_agent(): Return a unique identifier or name of the agent. + - register_sa_agent(): Perform initialization or registration steps for the agent. + - suggest_action(sac): Submit a suggested command (SACommand) for arbitration. + - notify_all_suggestions_done(event_type): Indicate that all suggestions for a given event are complete. + """ + + @abstractmethod + async def get_agent(self) -> str: + """ + Return the unique identifier or name of the agent. + + Returns: + str: The identifier or label representing this SA agent. + """ + raise NotImplementedError + + @abstractmethod + async def register_sa_agent(self): + """ + Perform initialization logic required to register this SA agent + within the system (e.g., announcing its presence or preparing state). + """ + raise NotImplementedError + + @abstractmethod + async def suggest_action(self, sac: SACommand): + """ + Submit a suggested action in the form of a SACommand for a given context. + + Parameters: + sac (SACommand): The command proposed by the agent for execution. + """ + raise NotImplementedError + + @abstractmethod + async def notify_all_suggestions_done(self, event_type): + """ + Notify that this agent has completed all its suggestions for a particular event. + + Parameters: + event_type (Type[NodeEvent]): The type of the event for which suggestions are now complete. + """ + raise NotImplementedError diff --git a/nebula/core/situationalawareness/awareness/sautils/sasystemmonitor.py b/nebula/core/situationalawareness/awareness/sautils/sasystemmonitor.py new file mode 100644 index 000000000..c9feb6e87 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/sautils/sasystemmonitor.py @@ -0,0 +1,165 @@ +import asyncio +import platform + +import psutil +from pynvml import ( + nvmlDeviceGetCount, + nvmlDeviceGetHandleByIndex, + nvmlDeviceGetMemoryInfo, + nvmlDeviceGetUtilizationRates, + nvmlInit, + nvmlShutdown, +) + +from nebula.core.utils.locker import Locker + + +class SystemMonitor: + _instance = None + _lock = Locker("communications_manager_lock", async_lock=False) + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls): + """Obtain SystemMonitor instance""" + if cls._instance is None: + raise ValueError("SystemMonitor has not been initialized yet.") + return cls._instance + + def __init__(self): + """Initialize the system monitor and check for GPU availability.""" + if not hasattr(self, "_initialized"): # To avoid reinitialization on subsequent calls + # Try to initialize NVIDIA library if available + try: + nvmlInit() + self.gpu_available = True # Flag to check if GPU is available + except Exception: + self.gpu_available = False # If not, set GPU availability to False + self._initialized = True + + async def get_cpu_usage(self): + """Returns the CPU usage percentage.""" + return psutil.cpu_percent(interval=1) + + async def get_cpu_per_core_usage(self): + """Returns the CPU usage percentage per core.""" + return psutil.cpu_percent(interval=1, percpu=True) + + async def get_memory_usage(self): + """Returns the percentage of used RAM memory.""" + memory_info = psutil.virtual_memory() + return memory_info.percent + + async def get_swap_memory_usage(self): + """Returns the percentage of used swap memory.""" + swap_info = psutil.swap_memory() + return swap_info.percent + + async def get_network_usage(self, interval=5): + """Measures network usage over a time interval and returns bandwidth percentage usage.""" + os_name = platform.system() + + # Get max bandwidth (only implemented for Linux) + if os_name == "Linux": + max_bandwidth = self._get_max_bandwidth_linux() + else: + max_bandwidth = None + + # Take first measurement + net_io_start = psutil.net_io_counters() + bytes_sent_start = net_io_start.bytes_sent + bytes_recv_start = net_io_start.bytes_recv + + # Wait for the interval + await asyncio.sleep(interval) + + # Take second measurement + net_io_end = psutil.net_io_counters() + bytes_sent_end = net_io_end.bytes_sent + bytes_recv_end = net_io_end.bytes_recv + + # Calculate bytes transferred during interval + bytes_sent = bytes_sent_end - bytes_sent_start + bytes_recv = bytes_recv_end - bytes_recv_start + total_bytes = bytes_sent + bytes_recv + + # Calculate bandwidth usage percentage + bandwidth_used_percent = self._calculate_bandwidth_usage(total_bytes, max_bandwidth, interval) + + return { + "interval": interval, + "bytes_sent": bytes_sent, + "bytes_recv": bytes_recv, + "bandwidth_used_percent": bandwidth_used_percent, + "bandwidth_max": max_bandwidth, + } + + # TODO catched speed to avoid reading file + def _get_max_bandwidth_linux(self, interface="eth0"): + """Reads max bandwidth from /sys/class/net/{iface}/speed (Linux only).""" + try: + with open(f"/sys/class/net/{interface}/speed") as f: + speed = int(f.read().strip()) # In Mbps + return speed + except Exception as e: + print(f"Could not read max bandwidth: {e}") + return None + + def _calculate_bandwidth_usage(self, bytes_transferred, max_bandwidth_mbps, interval): + """Calculates bandwidth usage percentage over the given interval.""" + if max_bandwidth_mbps is None or interval <= 0: + return None + + try: + # Convert bytes to megabits + megabits_transferred = (bytes_transferred * 8) / (1024 * 1024) + # Calculate usage in Mbps + current_usage_mbps = megabits_transferred / interval + # Percentage of max bandwidth + usage_percentage = (current_usage_mbps / max_bandwidth_mbps) * 100 + return usage_percentage + except Exception as e: + print(f"Error calculating bandwidth usage: {e}") + return None + + async def get_gpu_usage(self): + """Returns GPU usage stats if available, otherwise returns None.""" + if not self.gpu_available: + return None # No GPU available, return None + + # If GPU is available, get the usage using pynvml + device_count = nvmlDeviceGetCount() + gpu_usage = [] + for i in range(device_count): + handle = nvmlDeviceGetHandleByIndex(i) + memory_info = nvmlDeviceGetMemoryInfo(handle) + utilization = nvmlDeviceGetUtilizationRates(handle) + gpu_usage.append({ + "gpu": i, + "memory_used": memory_info.used / 1024**2, # MB + "memory_total": memory_info.total / 1024**2, # MB + "gpu_usage": utilization.gpu, + }) + return gpu_usage + + async def get_system_resources(self): + """Returns a dictionary with all system resource usage statistics.""" + resources = { + "cpu_usage": await self.get_cpu_usage(), + "cpu_per_core_usage": await self.get_cpu_per_core_usage(), + "memory_usage": await self.get_memory_usage(), + "swap_memory_usage": await self.get_swap_memory_usage(), + "network_usage": await self.get_network_usage(), + "gpu_usage": await self.get_gpu_usage(), # Includes GPU usage or None if no GPU + } + return resources + + async def close(self): + """Closes the initialization of the NVIDIA library (if used).""" + if self.gpu_available: + nvmlShutdown() diff --git a/nebula/core/situationalawareness/awareness/suggestionbuffer.py b/nebula/core/situationalawareness/awareness/suggestionbuffer.py new file mode 100644 index 000000000..98cae49b2 --- /dev/null +++ b/nebula/core/situationalawareness/awareness/suggestionbuffer.py @@ -0,0 +1,198 @@ +import asyncio +from collections import defaultdict + +from nebula.core.nebulaevents import NodeEvent +from nebula.core.situationalawareness.awareness.sautils.sacommand import SACommand +from nebula.core.situationalawareness.awareness.sautils.samoduleagent import SAModuleAgent +from nebula.core.utils.locker import Locker +from nebula.utils import logging + + +class SuggestionBuffer: + """ + Singleton class that manages the coordination of suggestions from Situational Awareness (SA) agents. + + The SuggestionBuffer stores, synchronizes, and tracks command suggestions issued by agents in + response to specific node events. It ensures that all expected agents have submitted their input + before triggering arbitration. Internally, it maintains buffers for suggestions, synchronization + locks, and agent-specific notifications to guarantee consistency in distributed settings. + + Main Responsibilities: + - Register expected agents for an event and track their completion. + - Store and retrieve suggestions for arbitration. + - Signal the arbitrator once all expected suggestions have been received. + - Support safe concurrent access through async-aware locking mechanisms. + """ + + _instance = None + _lock = Locker("initialize_sb_lock", async_lock=False) + + def __new__(cls, arbitrator_notification, verbose): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls): + """Obtain SuggestionBuffer instance""" + if cls._instance is None: + raise ValueError("SuggestionBuffer has not been initialized yet.") + return cls._instance + + def __init__(self, arbitrator_notification: asyncio.Event, verbose): + """Initializes the suggestion buffer with thread-safe synchronization.""" + self._arbitrator_notification = arbitrator_notification + self._arbitrator_notification_lock = Locker("arbitrator_notification_lock", async_lock=True) + self._verbose = verbose + self._buffer: dict[type[NodeEvent], list[tuple[SAModuleAgent, SACommand]]] = defaultdict(list) + self._suggestion_buffer_lock = Locker("suggestion_buffer_lock", async_lock=True) + self._expected_agents: dict[type[NodeEvent], list[SAModuleAgent]] = defaultdict(list) + self._expected_agents_lock = Locker("expected_agents_lock", async_lock=True) + self._event_notifications: dict[type[NodeEvent], list[tuple[SAModuleAgent, asyncio.Event]]] = defaultdict(list) + self._event_waited = None + + async def register_event_agents(self, event_type, agent: SAModuleAgent): + """ + Register a Situational Awareness (SA) agent as an expected participant for a given event type. + + Parameters: + event_type (Type[NodeEvent]): The type of event being registered. + agent (SAModuleAgent): The agent expected to submit suggestions for the event. + """ + async with self._expected_agents_lock: + if self._verbose: + logging.info(f"Registering SA Agent: {await agent.get_agent()} for event: {event_type.__name__}") + + if event_type not in self._event_notifications: + self._event_notifications[event_type] = [] + + self._expected_agents[event_type].append(agent) + + existing_agents = {a for a, _ in self._event_notifications[event_type]} + if agent not in existing_agents: + self._event_notifications[event_type].append((agent, asyncio.Event())) + + async def register_suggestion(self, event_type, agent: SAModuleAgent, suggestion: SACommand): + """ + Register a suggestion issued by a specific SA agent for a given event. + + Parameters: + event_type (Type[NodeEvent]): The event type for which the suggestion is made. + agent (SAModuleAgent): The agent submitting the suggestion. + suggestion (SACommand): The command being suggested. + """ + async with self._suggestion_buffer_lock: + if self._verbose: + logging.info( + f"Registering Suggestion from SA Agent: {await agent.get_agent()} for event: {event_type.__name__}" + ) + self._buffer[event_type].append((agent, suggestion)) + + async def set_event_waited(self, event_type): + """ + Set the event type that the SuggestionBuffer will wait for. + + Used to indicate that arbitration should proceed when all suggestions for this event are received. + + Parameters: + event_type (Type[NodeEvent]): The event type to monitor. + """ + if not self._event_waited: + if self._verbose: + logging.info( + f"Set notification when all suggestions have being received for event: {event_type.__name__}" + ) + self._event_waited = event_type + await self._notify_arbitrator(event_type) + + async def notify_all_suggestions_done_for_agent(self, saa: SAModuleAgent, event_type): + """ + Notify that a specific SA agent has completed its suggestion submission for an event. + + Parameters: + saa (SAModuleAgent): The notifying agent. + event_type (Type[NodeEvent]): The related event type. + """ + async with self._expected_agents_lock: + agent_found = False + for agent, event in self._event_notifications.get(event_type, []): + if agent == saa: + event.set() + agent_found = True + if self._verbose: + logging.info( + f"SA Agent: {await saa.get_agent()} notifies all suggestions registered for event: {event_type.__name__}" + ) + break + if not agent_found and self._verbose: + logging.error( + f"SAModuleAgent: {await saa.get_agent()} not found on notifications awaited for event {event_type.__name__}" + ) + await self._notify_arbitrator(event_type) + + async def _notify_arbitrator(self, event_type): + """ + Check if all expected agents have submitted their suggestions for the current awaited event. + + If so, notifies the arbitrator via the provided asyncio event. + """ + if event_type != self._event_waited: + return + + async with self._arbitrator_notification_lock: + async with self._expected_agents_lock: + expected_agents = self._expected_agents.get(event_type, []) + notifications = self._event_notifications.get(event_type, list()) + + agent_event_map = {a: e for a, e in notifications} + all_received = all( + agent in agent_event_map and agent_event_map[agent].is_set() for agent in expected_agents + ) + + if all_received: + self._arbitrator_notification.set() + self._event_waited = None + await self._reset_notifications_for_agents(event_type, expected_agents) + + async def _reset_notifications_for_agents(self, event_type, agents): + """ + Reset all notification events for the given agents tied to a specific event. + + Parameters: + event_type (Type[NodeEvent]): The event for which to reset agent notifications. + agents (list[SAModuleAgent]): The list of agents to reset. + """ + notifications = self._event_notifications.get(event_type, set()) + for agent, event in notifications: + if agent in agents: + event.clear() + + async def get_suggestions(self, event_type) -> list[tuple[SAModuleAgent, SACommand]]: + """ + Retrieve and return all suggestions for a given event type. + + Also clears the buffer after reading. + + Parameters: + event_type (Type[NodeEvent]): The event whose suggestions are requested. + + Returns: + list[tuple[SAModuleAgent, SACommand]]: List of (agent, suggestion) pairs. + """ + async with self._suggestion_buffer_lock: + async with self._expected_agents_lock: + suggestions = list(self._buffer.get(event_type, [])) + if self._verbose: + logging.info(f"Retrieving all sugestions for event: {event_type.__name__}") + await self._clear_suggestions(event_type) + return suggestions + + async def _clear_suggestions(self, event_type): + """ + Clear the buffer and associated data for a specific event type. + + Parameters: + event_type (Type[NodeEvent]): The event whose stored suggestions are to be removed. + """ + self._buffer[event_type].clear() diff --git a/nebula/core/situationalawareness/discovery/candidateselection/__init__.py b/nebula/core/situationalawareness/discovery/candidateselection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nebula/core/situationalawareness/discovery/candidateselection/candidateselector.py b/nebula/core/situationalawareness/discovery/candidateselection/candidateselector.py new file mode 100644 index 000000000..4771888a8 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/candidateselection/candidateselector.py @@ -0,0 +1,71 @@ +from abc import ABC, abstractmethod + + +class CandidateSelector(ABC): + @abstractmethod + async def set_config(self, config): + """ + Configure internal parameters for the candidate selection strategy. + + Parameters: + config: A configuration object or dictionary with necessary parameters. + """ + pass + + @abstractmethod + async def add_candidate(self, candidate): + """ + Add a new candidate to the internal pool of potential selections. + + Parameters: + candidate: The candidate node or object to be considered for selection. + """ + pass + + @abstractmethod + async def select_candidates(self): + """ + Apply the selection logic to choose the best candidates from the internal pool. + + Returns: + list: A list of selected candidates based on the implemented strategy. + """ + pass + + @abstractmethod + async def remove_candidates(self): + """ + Remove one or more candidates from the pool based on internal rules or external decisions. + """ + pass + + @abstractmethod + async def any_candidate(self): + """ + Check whether there are any candidates currently available in the internal pool. + + Returns: + bool: True if at least one candidate is available, False otherwise. + """ + pass + + +def factory_CandidateSelector(selector) -> CandidateSelector: + from nebula.core.situationalawareness.discovery.candidateselection.distcandidateselector import ( + DistanceCandidateSelector, + ) + from nebula.core.situationalawareness.discovery.candidateselection.fccandidateselector import FCCandidateSelector + from nebula.core.situationalawareness.discovery.candidateselection.ringcandidateselector import ( + RINGCandidateSelector, + ) + from nebula.core.situationalawareness.discovery.candidateselection.stdcandidateselector import STDandidateSelector + + options = { + "ring": RINGCandidateSelector, + "fully": FCCandidateSelector, + "random": STDandidateSelector, + "distance": DistanceCandidateSelector, + } + + cs = options.get(selector, FCCandidateSelector) + return cs() diff --git a/nebula/core/situationalawareness/discovery/candidateselection/distcandidateselector.py b/nebula/core/situationalawareness/discovery/candidateselection/distcandidateselector.py new file mode 100644 index 000000000..c40cd5e39 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/candidateselection/distcandidateselector.py @@ -0,0 +1,52 @@ +import logging + +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import GPSEvent +from nebula.core.situationalawareness.discovery.candidateselection.candidateselector import CandidateSelector +from nebula.core.utils.locker import Locker + + +class DistanceCandidateSelector(CandidateSelector): + # INFO: This value may change according to the needs of the federation + MAX_DISTANCE_THRESHOLD = 200 + + def __init__(self): + self.candidates = [] + self.candidates_lock = Locker(name="candidates_lock", async_lock=True) + self.nodes_distances: dict[str, tuple[float, tuple[float, float]]] = None + self.nodes_distances_lock = Locker("nodes_distances_lock", async_lock=True) + self._verbose = False + + async def set_config(self, config): + await EventManager.get_instance().subscribe_addonevent(GPSEvent, self._udpate_distances) + + async def _udpate_distances(self, gpsevent: GPSEvent): + async with self.nodes_distances_lock: + distances = await gpsevent.get_event_data() + self.nodes_distances = distances + + async def add_candidate(self, candidate): + async with self.candidates_lock: + self.candidates.append(candidate) + + async def select_candidates(self): + async with self.candidates_lock: + async with self.nodes_distances_lock: + nodes_available = [ + candidate + for candidate in self.candidates + if candidate[0] in self.nodes_distances + and self.nodes_distances[candidate[0]][0] < self.MAX_DISTANCE_THRESHOLD + ] + if self._verbose: + logging.info(f"Nodes availables: {nodes_available}") + return (nodes_available, []) + + async def remove_candidates(self): + async with self.candidates_lock: + self.candidates = [] + + async def any_candidate(self): + async with self.candidates_lock: + any = True if len(self.candidates) > 0 else False + return any diff --git a/nebula/core/situationalawareness/discovery/candidateselection/fccandidateselector.py b/nebula/core/situationalawareness/discovery/candidateselection/fccandidateselector.py new file mode 100644 index 000000000..fd1e4442c --- /dev/null +++ b/nebula/core/situationalawareness/discovery/candidateselection/fccandidateselector.py @@ -0,0 +1,36 @@ +from nebula.core.situationalawareness.discovery.candidateselection.candidateselector import CandidateSelector +from nebula.core.utils.locker import Locker + + +class FCCandidateSelector(CandidateSelector): + def __init__(self): + self.candidates = [] + self.candidates_lock = Locker(name="candidates_lock") + + async def set_config(self, config): + pass + + async def add_candidate(self, candidate): + self.candidates_lock.acquire() + self.candidates.append(candidate) + self.candidates_lock.release() + + async def select_candidates(self): + """ + In Fully-Connected topology all candidates should be selected + """ + self.candidates_lock.acquire() + cdts = self.candidates.copy() + self.candidates_lock.release() + return (cdts, []) + + async def remove_candidates(self): + self.candidates_lock.acquire() + self.candidates = [] + self.candidates_lock.release() + + async def any_candidate(self): + self.candidates_lock.acquire() + any = True if len(self.candidates) > 0 else False + self.candidates_lock.release() + return any diff --git a/nebula/core/situationalawareness/discovery/candidateselection/ringcandidateselector.py b/nebula/core/situationalawareness/discovery/candidateselection/ringcandidateselector.py new file mode 100644 index 000000000..a7cd377e6 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/candidateselection/ringcandidateselector.py @@ -0,0 +1,52 @@ +import random + +from nebula.core.situationalawareness.discovery.candidateselection.candidateselector import CandidateSelector +from nebula.core.utils.locker import Locker + + +class RINGCandidateSelector(CandidateSelector): + def __init__(self): + self._candidates = [] + self._rejected_candidates = [] + self.candidates_lock = Locker(name="candidates_lock") + + async def set_config(self, config): + pass + + async def add_candidate(self, candidate): + """ + To avoid topology problems select 1st candidate found + """ + self.candidates_lock.acquire() + self._candidates.append(candidate) + self.candidates_lock.release() + + async def select_candidates(self): + self.candidates_lock.acquire() + cdts = [] + + if self._candidates: + min_neighbors = min(self._candidates, key=lambda x: x[1])[1] + tied_candidates = [c for c in self._candidates if c[1] == min_neighbors] + + selected = random.choice(tied_candidates) + cdts.append(selected) + + for cdt in self._candidates: + if cdt not in cdts: + self._rejected_candidates.append(cdt) + + not_cdts = self._rejected_candidates.copy() + self.candidates_lock.release() + return (cdts, not_cdts) + + async def remove_candidates(self): + self.candidates_lock.acquire() + self._candidates = [] + self.candidates_lock.release() + + async def any_candidate(self): + self.candidates_lock.acquire() + any = True if len(self._candidates) > 0 else False + self.candidates_lock.release() + return any diff --git a/nebula/core/situationalawareness/discovery/candidateselection/stdcandidateselector.py b/nebula/core/situationalawareness/discovery/candidateselection/stdcandidateselector.py new file mode 100644 index 000000000..0ea11a023 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/candidateselection/stdcandidateselector.py @@ -0,0 +1,41 @@ +import logging + +from nebula.core.situationalawareness.discovery.candidateselection.candidateselector import CandidateSelector +from nebula.core.utils.locker import Locker + + +class STDandidateSelector(CandidateSelector): + def __init__(self): + self.candidates = [] + self.candidates_lock = Locker(name="candidates_lock") + + async def set_config(self, config): + pass + + async def add_candidate(self, candidate): + self.candidates_lock.acquire() + self.candidates.append(candidate) + self.candidates_lock.release() + + async def select_candidates(self): + """ + Select mean number of neighbors + """ + self.candidates_lock.acquire() + mean_neighbors = round(sum(n for _, n, _ in self.candidates) / len(self.candidates) if self.candidates else 0) + logging.info(f"mean number of neighbors: {mean_neighbors}") + cdts = self.candidates[:mean_neighbors] + not_selected = set(self.candidates) - set(cdts) + self.candidates_lock.release() + return (cdts, not_selected) + + async def remove_candidates(self): + self.candidates_lock.acquire() + self.candidates = [] + self.candidates_lock.release() + + async def any_candidate(self): + self.candidates_lock.acquire() + any = True if len(self.candidates) > 0 else False + self.candidates_lock.release() + return any diff --git a/nebula/core/situationalawareness/discovery/federationconnector.py b/nebula/core/situationalawareness/discovery/federationconnector.py new file mode 100644 index 000000000..ba001925c --- /dev/null +++ b/nebula/core/situationalawareness/discovery/federationconnector.py @@ -0,0 +1,491 @@ +import asyncio +import logging +from functools import cached_property +from typing import TYPE_CHECKING + +from nebula.addons.functions import print_msg_box +from nebula.core.eventmanager import EventManager +from nebula.core.nebulaevents import NodeFoundEvent, UpdateNeighborEvent +from nebula.core.network.communications import CommunicationsManager +from nebula.core.situationalawareness.discovery.candidateselection.candidateselector import factory_CandidateSelector +from nebula.core.situationalawareness.discovery.modelhandlers.modelhandler import factory_ModelHandler +from nebula.core.situationalawareness.situationalawareness import ISADiscovery, ISAReasoner +from nebula.core.utils.locker import Locker + +if TYPE_CHECKING: + from nebula.core.engine import Engine + +OFFER_TIMEOUT = 7 +PENDING_CONFIRMATION_TTL = 30 + + +class FederationConnector(ISADiscovery): + """ + Responsible for the discovery and operational management of the federation within the Situational Awareness module. + + The FederationConnector implements the ISADiscovery interface and coordinates both the discovery + of participants in the federation and the operational steps required to integrate them into the + Situational Awareness (SA) workflow. Its responsibilities include: + + - Initiating the discovery process using the configured CandidateSelector and ModelHandler. + - Managing neighbor evaluation and model exchange. + - Interfacing with the SAReasoner to accept connections and ask for actions to do in response. + - Applying neighbor policies and orchestrating topology changes. + - Acting as the operational core of the SA module by executing workflows and ensuring coordination. + + This class bridges the discovery logic with situational response capabilities in decentralized or federated systems. + """ + + def __init__(self, aditional_participant, selector, model_handler, engine: "Engine", verbose=False): + self._aditional_participant = aditional_participant + self._selector = selector + print_msg_box(msg="Starting FederationConnector module...", indent=2, title="FederationConnector module") + logging.info("🌐 Initializing Federation Connector") + self._engine = engine + self._cm = None + self.config = engine.get_config() + logging.info("Initializing Candidate Selector") + self._candidate_selector = factory_CandidateSelector(self._selector) + logging.info("Initializing Model Handler") + self._model_handler = factory_ModelHandler(model_handler) + self._update_neighbors_lock = Locker(name="_update_neighbors_lock", async_lock=True) + self.late_connection_process_lock = Locker(name="late_connection_process_lock") + self.pending_confirmation_from_nodes = set() + self.pending_confirmation_from_nodes_lock = Locker(name="pending_confirmation_from_nodes_lock", async_lock=True) + self.accept_candidates_lock = Locker(name="accept_candidates_lock") + self.recieve_offer_timer = OFFER_TIMEOUT + self.discarded_offers_addr_lock = Locker(name="discarded_offers_addr_lock", async_lock=True) + self.discarded_offers_addr = [] + + self._sa_reasoner: ISAReasoner = None + self._verbose = verbose + + @property + def engine(self): + return self._engine + + @cached_property + def cm(self): + return CommunicationsManager.get_instance() + + @property + def candidate_selector(self): + return self._candidate_selector + + @property + def model_handler(self): + return self._model_handler + + @property + def sar(self): + """Situational Awareness Reasoner""" + return self._sa_reasoner + + async def init(self, sa_reasoner): + """ + model_handler config: + - self total rounds + - self current round + - self epochs + + candidate_selector config: + - self model loss + - self weight distance + - self weight hetereogeneity + """ + logging.info("Building Federation Connector configurations...") + self._sa_reasoner: ISAReasoner = sa_reasoner + await self._register_message_events_callbacks() + await EventManager.get_instance().subscribe_node_event(UpdateNeighborEvent, self._update_neighbors) + await EventManager.get_instance().subscribe(("model", "update"), self._model_update_callback) + + logging.info("Building candidate selector configuration..") + await self.candidate_selector.set_config([0, 0.5, 0.5]) + # self.engine.trainer.get_loss(), self.config.participant["molibity_args"]["weight_distance"], self.config.participant["molibity_args"]["weight_het"] + + """ + ############################## + # CONNECTIONS # + ############################## + """ + + async def _accept_connection(self, source, joining=False): + return await self.sar.accept_connection(source, joining) + + def _still_waiting_for_candidates(self): + return not self.accept_candidates_lock.locked() and self.late_connection_process_lock.locked() + + async def _add_pending_connection_confirmation(self, addr): + added = False + async with self._update_neighbors_lock: + async with self.pending_confirmation_from_nodes_lock: + if addr not in await self.sar.get_nodes_known(neighbors_only=True): + if addr not in self.pending_confirmation_from_nodes: + logging.info(f"Addition | pending connection confirmation from: {addr}") + self.pending_confirmation_from_nodes.add(addr) + added = True + if added: + await self._clear_pending_confirmations(node=addr) + + async def _remove_pending_confirmation_from(self, addr): + async with self.pending_confirmation_from_nodes_lock: + self.pending_confirmation_from_nodes.discard(addr) + + async def _clear_pending_confirmations(self, node): + await asyncio.sleep(PENDING_CONFIRMATION_TTL) + async with self.pending_confirmation_from_nodes_lock: + if node in self.pending_confirmation_from_nodes: + self.pending_confirmation_from_nodes.discard(node) + + async def _waiting_confirmation_from(self, addr): + async with self.pending_confirmation_from_nodes_lock: + found = addr in self.pending_confirmation_from_nodes + return found + + async def _confirmation_received(self, addr, confirmation=True, joining=False): + logging.info(f" Update | connection confirmation received from: {addr} | joining federation: {joining}") + await self._remove_pending_confirmation_from(addr) + if confirmation: + await self.cm.connect(addr, direct=True) + une = UpdateNeighborEvent(addr, joining=joining) + await EventManager.get_instance().publish_node_event(une) + + async def _add_to_discarded_offers(self, addr_discarded): + async with self.discarded_offers_addr_lock: + self.discarded_offers_addr.append(addr_discarded) + + async def _get_actions(self): + return await self.sar.get_actions() + + async def _register_late_neighbor(self, addr, joinning_federation=False): + if self._verbose: + logging.info(f"Registering | late neighbor: {addr}, joining: {joinning_federation}") + une = UpdateNeighborEvent(addr, joining=joinning_federation) + await EventManager.get_instance().publish_node_event(une) + + async def _update_neighbors(self, une: UpdateNeighborEvent): + node, remove = await une.get_event_data() + await self._update_neighbors_lock.acquire_async() + if not remove: + await self._meet_node(node) + await self._remove_pending_confirmation_from(node) + await self._update_neighbors_lock.release_async() + + async def _meet_node(self, node): + nfe = NodeFoundEvent(node) + await EventManager.get_instance().publish_node_event(nfe) + + async def accept_model_offer(self, source, decoded_model, rounds, round, epochs, n_neighbors, loss): + """ + Evaluate and possibly accept a model offer from a remote source. + + Parameters: + source (str): Identifier of the node sending the model. + decoded_model (object): The model received and decoded from the sender. + rounds (int): Total number of training rounds in the current session. + round (int): Current round. + epochs (int): Number of epochs assigned for local training. + n_neighbors (int): Number of neighbors of the sender. + loss (float): Loss value associated with the proposed model. + + Returns: + bool: True if the model is accepted and the sender added as a candidate, False otherwise. + """ + if not self.accept_candidates_lock.locked(): + if self._verbose: + logging.info(f"πŸ”„ Processing offer from {source}...") + model_accepted = self.model_handler.accept_model(decoded_model) + self.model_handler.set_config(config=(rounds, round, epochs, self)) + if model_accepted: + await self.candidate_selector.add_candidate((source, n_neighbors, loss)) + return True + else: + return False + + async def get_trainning_info(self): + return await self.model_handler.get_model(None) + + async def _add_candidate(self, source, n_neighbors, loss): + if not self.accept_candidates_lock.locked(): + await self.candidate_selector.add_candidate((source, n_neighbors, loss)) + + async def _stop_not_selected_connections(self, rejected: set = {}): + """ + Asynchronously stop connections that were not selected after a waiting period. + + Parameters: + rejected (set): A set of node addresses that were explicitly rejected + and should be marked for disconnection. + """ + await asyncio.sleep(20) + for r in rejected: + await self._add_to_discarded_offers(r) + + try: + async with self.discarded_offers_addr_lock: + if len(self.discarded_offers_addr) > 0: + self.discarded_offers_addr = set(self.discarded_offers_addr).difference_update( + await self.cm.get_addrs_current_connections(only_direct=True, myself=False) + ) + if self._verbose: + logging.info( + f"Interrupting connections | discarded offers | nodes discarded: {self.discarded_offers_addr}" + ) + for addr in self.discarded_offers_addr: + if not self._waiting_confirmation_from(addr): + await self.cm.disconnect(addr, mutual_disconnection=True) + await asyncio.sleep(1) + self.discarded_offers_addr = [] + except asyncio.CancelledError: + pass + + async def start_late_connection_process(self, connected=False, msg_type="discover_join", addrs_known=None): + """ + This function represents the process of discovering the federation and stablish the first + connections with it. The first step is to send the DISCOVER_JOIN/NODES message to look for nodes, + the ones that receive that message will send back a OFFER_MODEL/METRIC message. It contains info to do + a selection process among candidates to later on connect to the best ones. + The process will repeat until at least one candidate is found and the process will be locked + to avoid concurrency. + """ + logging.info("🌐 Initializing late connection process..") + + self.late_connection_process_lock.acquire() + best_candidates = [] + await self.candidate_selector.remove_candidates() + + # find federation and send discover + discovers_sent, connections_stablished = await self.cm.stablish_connection_to_federation(msg_type, addrs_known) + + # wait offer + if self._verbose: + logging.info(f"Discover messages sent after finding federation: {discovers_sent}") + if discovers_sent: + if self._verbose: + logging.info(f"Waiting: {self.recieve_offer_timer}s to receive offers from federation") + await asyncio.sleep(self.recieve_offer_timer) + + # acquire lock to not accept late candidates + self.accept_candidates_lock.acquire() + + if await self.candidate_selector.any_candidate(): + if self._verbose: + logging.info("Candidates found to connect to...") + # create message to send to candidates selected + if not connected: + msg = self.cm.create_message("connection", "late_connect") + else: + msg = self.cm.create_message("connection", "restructure") + + best_candidates, rejected_candidates = await self.candidate_selector.select_candidates() + if self._verbose: + logging.info(f"Candidates | {[addr for addr, _, _ in best_candidates]}") + try: + for addr, _, _ in best_candidates: + await self._add_pending_connection_confirmation(addr) + await self.cm.send_message(addr, msg) + except asyncio.CancelledError: + if self._verbose: + logging.info("Error during stablishment") + + self.accept_candidates_lock.release() + self.late_connection_process_lock.release() + await self.candidate_selector.remove_candidates() + logging.info("🌐 Ending late connection process..") + # if no candidates, repeat process + else: + if self._verbose: + logging.info("❗️ No Candidates found...") + self.accept_candidates_lock.release() + self.late_connection_process_lock.release() + # if not connected: + # if self._verbose: logging.info("❗️ repeating process...") + # await self.start_late_connection_process(connected, msg_type, addrs_known) + + """ ############################## + # Mobility callbacks # + ############################## + """ + + async def _register_message_events_callbacks(self): + me_dict = self.cm.get_messages_events() + message_events = [ + (message_name, message_action) + for (message_name, message_actions) in me_dict.items() + for message_action in message_actions + ] + for event_type, action in message_events: + callback_name = f"_{event_type}_{action}_callback" + method = getattr(self, callback_name, None) + + if callable(method): + await EventManager.get_instance().subscribe((event_type, action), method) + + async def _connection_disconnect_callback(self, source, message): + if await self._waiting_confirmation_from(source): + await self._confirmation_received(source, confirmation=False) + + async def _model_update_callback(self, source, message): + if await self._waiting_confirmation_from(source): + await self._confirmation_received(source) + + async def _connection_late_connect_callback(self, source, message): + logging.info(f"πŸ”— handle_connection_message | Trigger | Received late connect message from {source}") + # Verify if it's a confirmation message from a previous late connection message sent to source + if await self._waiting_confirmation_from(source): + await self._confirmation_received(source, joining=True) + return + + if not self.engine.get_initialization_status(): + logging.info("❗️ Connection refused | Device not initialized yet...") + return + + if await self._accept_connection(source, joining=True): + logging.info(f"πŸ”— handle_connection_message | Late connection accepted | source: {source}") + await self.cm.connect(source, direct=True) + + # Verify conenction is accepted + conf_msg = self.cm.create_message("connection", "late_connect") + await self.cm.send_message(source, conf_msg) + + ct_actions, df_actions = await self._get_actions() + if len(ct_actions): + logging.info(f"{ct_actions}") + cnt_msg = self.cm.create_message("link", "connect_to", addrs=ct_actions) + await self.cm.send_message(source, cnt_msg) + + if len(df_actions): + logging.info("2 acciones") + logging.info(f"{df_actions}") + for addr in df_actions.split(): + await self.cm.disconnect(addr, mutual_disconnection=True) + + await self._register_late_neighbor(source, joinning_federation=True) + + else: + logging.info(f"❗️ Late connection NOT accepted | source: {source}") + + async def _connection_restructure_callback(self, source, message): + logging.info(f"πŸ”— handle_connection_message | Trigger | Received restructure message from {source}") + # Verify if it's a confirmation message from a previous restructure connection message sent to source + if await self._waiting_confirmation_from(source): + await self._confirmation_received(source, joining=False) + return + + if not self.engine.get_initialization_status(): + logging.info("❗️ Connection refused | Device not initialized yet...") + return + + if await self._accept_connection(source, joining=False): + logging.info(f"πŸ”— handle_connection_message | Trigger | restructure connection accepted from {source}") + await self.cm.connect(source, direct=True) + + conf_msg = self.cm.create_message("connection", "restructure") + await self.cm.send_message(source, conf_msg) + + ct_actions, df_actions = await self._get_actions() + if len(ct_actions): + cnt_msg = self.cm.create_message("link", "connect_to", addrs=ct_actions) + await self.cm.send_message(source, cnt_msg) + + if len(df_actions): + for addr in df_actions.split(): + await self.cm.disconnect(addr, mutual_disconnection=True) + # df_msg = self.cm.create_message("link", "disconnect_from", addrs=df_actions) + # await self.cm.send_message(source, df_msg) + + await self._register_late_neighbor(source, joinning_federation=False) + else: + logging.info(f"❗️ handle_connection_message | Trigger | restructure connection denied from {source}") + + async def _discover_discover_join_callback(self, source, message): + logging.info(f"πŸ” handle_discover_message | Trigger | Received discover_join message from {source} ") + if len(await self.engine.get_federation_nodes()) > 0: + await self.engine.trainning_in_progress_lock.acquire_async() + model, rounds, round = ( + await self.cm.propagator.get_model_information(source, "stable") + if self.engine.get_round() > 0 + else await self.cm.propagator.get_model_information(source, "initialization") + ) + await self.engine.trainning_in_progress_lock.release_async() + if round != -1: + epochs = self.config.participant["training_args"]["epochs"] + msg = self.cm.create_message( + "offer", + "offer_model", + len(await self.engine.get_federation_nodes()), + 0, + parameters=model, + rounds=rounds, + round=round, + epochs=epochs, + ) + logging.info(f"Sending offer model to {source}") + await self.cm.send_message(source, msg, message_type="offer_model") + else: + logging.info("Discover join received before federation is running..") + # starter node is going to send info to the new node + else: + logging.info(f"πŸ”— Dissmissing discover join from {source} | no active connections at the moment") + + async def _discover_discover_nodes_callback(self, source, message): + logging.info(f"πŸ” handle_discover_message | Trigger | Received discover_node message from {source} ") + if len(await self.engine.get_federation_nodes()) > 0: + if await self._accept_connection(source, joining=False): + msg = self.cm.create_message( + "offer", + "offer_metric", + n_neighbors=len(await self.engine.get_federation_nodes()), + loss=0, # self.engine.trainer.get_current_loss() + ) + logging.info(f"Sending offer metric to {source}") + await self.cm.send_message(source, msg) + else: + logging.info(f"πŸ”— Dissmissing discover nodes from {source} | no active connections at the moment") + + async def _offer_offer_model_callback(self, source, message): + logging.info(f"πŸ” handle_offer_message | Trigger | Received offer_model message from {source}") + await self._meet_node(source) + if self._still_waiting_for_candidates(): + try: + model_compressed = message.parameters + if await self.accept_model_offer( + source, + model_compressed, + message.rounds, + message.round, + message.epochs, + message.n_neighbors, + message.loss, + ): + logging.info(f"πŸ”§ Model accepted from offer | source: {source}") + else: + logging.info(f"❗️ Model offer discarded | source: {source}") + await self._add_to_discarded_offers(source) + except RuntimeError: + logging.info(f"❗️ Error proccesing offer model from {source}") + else: + logging.info( + f"❗️ handfle_offer_message | NOT accepting offers | waiting candidates: {self._still_waiting_for_candidates()}" + ) + await self._add_to_discarded_offers(source) + + async def _offer_offer_metric_callback(self, source, message): + logging.info(f"πŸ” handle_offer_message | Trigger | Received offer_metric message from {source}") + await self._meet_node(source) + if self._still_waiting_for_candidates(): + n_neighbors = message.n_neighbors + loss = message.loss + await self._add_candidate(source, n_neighbors, loss) + + async def _link_connect_to_callback(self, source, message): + logging.info(f"πŸ”— handle_link_message | Trigger | Received connect_to message from {source}") + addrs = message.addrs + for addr in addrs.split(): + await self._meet_node(addr) + + async def _link_disconnect_from_callback(self, source, message): + logging.info(f"πŸ”— handle_link_message | Trigger | Received disconnect_from message from {source}") + addrs = message.addrs + for addr in addrs.split(): + await asyncio.create_task(self.cm.disconnect(addr, mutual_disconnection=False)) diff --git a/nebula/core/situationalawareness/discovery/modelhandlers/__init__.py b/nebula/core/situationalawareness/discovery/modelhandlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nebula/core/situationalawareness/discovery/modelhandlers/aggmodelhandler.py b/nebula/core/situationalawareness/discovery/modelhandlers/aggmodelhandler.py new file mode 100644 index 000000000..2803a322b --- /dev/null +++ b/nebula/core/situationalawareness/discovery/modelhandlers/aggmodelhandler.py @@ -0,0 +1,52 @@ +from nebula.core.situationalawareness.discovery.modelhandlers.modelhandler import ModelHandler +from nebula.core.utils.locker import Locker + + +class AGGModelHandler(ModelHandler): + def __init__(self): + self.model = None + self.rounds = 0 + self.round = 0 + self.epochs = 1 + self.model_list = [] + self.models_lock = Locker(name="model_lock") + self.params_lock = Locker(name="param_lock") + + def set_config(self, config): + """ + Args: + config[0] -> total rounds + config[1] -> current round + config[2] -> epochs + """ + self.params_lock.acquire() + self.rounds = config[0] + if config[1] > self.round: + self.round = config[0] + self.epochs = config[2] + self.params_lock.release() + + def accept_model(self, model): + """ + Save first model receive and collect the rest for pre-processing + """ + self.models_lock.acquire() + if self.model is None: + self.model = model + else: + self.model_list.append(model) + self.models_lock.release() + + def get_model(self, model): + """ + Returns: + neccesary data to create trainer after pre-processing + """ + self.models_lock.acquire() + self.pre_process_model() + self.models_lock.release() + return (self.model, self.rounds, self.round, self.epochs) + + def pre_process_model(self): + # define pre-processing strategy + pass diff --git a/nebula/core/situationalawareness/discovery/modelhandlers/defaultmodelhandler.py b/nebula/core/situationalawareness/discovery/modelhandlers/defaultmodelhandler.py new file mode 100644 index 000000000..9c0ff2577 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/modelhandlers/defaultmodelhandler.py @@ -0,0 +1,48 @@ +from nebula.core.situationalawareness.discovery.federationconnector import FederationConnector +from nebula.core.situationalawareness.discovery.modelhandlers.modelhandler import ModelHandler +from nebula.core.utils.locker import Locker + + +class DefaultModelHandler(ModelHandler): + def __init__(self): + self.model = None + self.rounds = 0 + self.round = 0 + self.epochs = 0 + self.model_lock = Locker(name="model_lock") + self.params_lock = Locker(name="param_lock") + self._nm: FederationConnector = None + + def set_config(self, config): + """ + Args: + config[0] -> total rounds + config[1] -> current round + config[2] -> epochs + config[3] -> FederationConnector + """ + self.params_lock.acquire() + self.rounds = config[0] + if config[1] > self.round: + self.round = config[1] + self.epochs = config[2] + if not self._nm: + self._nm = config[3] + self.params_lock.release() + + def accept_model(self, model): + return True + + async def get_model(self, model): + """ + Returns: + model with default weights + """ + (sm, _, _) = await self._nm.engine.cm.propagator.get_model_information(None, "initialization", init=True) + return (sm, self.rounds, self.round, self.epochs) + + def pre_process_model(self): + """ + no pre-processing defined + """ + pass diff --git a/nebula/core/situationalawareness/discovery/modelhandlers/modelhandler.py b/nebula/core/situationalawareness/discovery/modelhandlers/modelhandler.py new file mode 100644 index 000000000..912b63a35 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/modelhandlers/modelhandler.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod + + +class ModelHandler(ABC): + @abstractmethod + def set_config(self, config): + """ + Configure internal settings for the model handler using the provided configuration. + + Parameters: + config: A configuration object or dictionary with parameters relevant to model handling. + """ + pass + + @abstractmethod + def accept_model(self, model): + """ + Evaluate and store a received model if it satisfies the required criteria. + + Parameters: + model: The model object to be processed or stored. + + Returns: + bool: True if the model is accepted, False otherwise. + """ + pass + + @abstractmethod + async def get_model(self, model): + """ + Asynchronously retrieve or generate the model to be used. + + Parameters: + model: A reference to the kind of model to be used. + + Returns: + object: The model instance requested. + """ + pass + + @abstractmethod + def pre_process_model(self): + """ + Perform any necessary preprocessing steps on the model before it is used. + + Returns: + object: The preprocessed model, ready for further operations. + """ + pass + + +def factory_ModelHandler(model_handler) -> ModelHandler: + from nebula.core.situationalawareness.discovery.modelhandlers.aggmodelhandler import AGGModelHandler + from nebula.core.situationalawareness.discovery.modelhandlers.defaultmodelhandler import DefaultModelHandler + from nebula.core.situationalawareness.discovery.modelhandlers.stdmodelhandler import STDModelHandler + + options = { + "std": STDModelHandler, + "default": DefaultModelHandler, + "aggregator": AGGModelHandler, + } + + cs = options.get(model_handler, STDModelHandler) + return cs() diff --git a/nebula/core/situationalawareness/discovery/modelhandlers/stdmodelhandler.py b/nebula/core/situationalawareness/discovery/modelhandlers/stdmodelhandler.py new file mode 100644 index 000000000..3e41b1a25 --- /dev/null +++ b/nebula/core/situationalawareness/discovery/modelhandlers/stdmodelhandler.py @@ -0,0 +1,51 @@ +from nebula.core.situationalawareness.discovery.modelhandlers.modelhandler import ModelHandler +from nebula.core.utils.locker import Locker + + +class STDModelHandler(ModelHandler): + def __init__(self): + self.model = None + self.rounds = 0 + self.round = 0 + self.epochs = 0 + self.model_lock = Locker(name="model_lock") + self.params_lock = Locker(name="param_lock") + + def set_config(self, config): + """ + Args: + config[0] -> total rounds + config[1] -> current round + config[2] -> epochs + """ + self.params_lock.acquire() + self.rounds = config[0] + if config[1] > self.round: + self.round = config[1] + self.epochs = config[2] + self.params_lock.release() + + def accept_model(self, model): + """ + save only first model received to set up own model later + """ + if not self.model_lock.locked(): + self.model_lock.acquire() + self.model = model + return True + + async def get_model(self, model): + """ + Returns: + neccesary data to create trainer + """ + if self.model is not None: + return (self.model, self.rounds, self.round, self.epochs) + else: + return (None, 0, 0, 0) + + def pre_process_model(self): + """ + no pre-processing defined + """ + pass diff --git a/nebula/core/situationalawareness/situationalawareness.py b/nebula/core/situationalawareness/situationalawareness.py new file mode 100644 index 000000000..dfbd176bd --- /dev/null +++ b/nebula/core/situationalawareness/situationalawareness.py @@ -0,0 +1,106 @@ +from abc import ABC, abstractmethod + +from nebula.addons.functions import print_msg_box + + +class ISADiscovery(ABC): + @abstractmethod + async def init(self, sa_reasoner): + raise NotImplementedError + + @abstractmethod + async def start_late_connection_process(self, connected=False, msg_type="discover_join", addrs_known=None): + raise NotImplementedError + + @abstractmethod + async def get_trainning_info(self): + raise NotImplementedError + + +class ISAReasoner(ABC): + @abstractmethod + async def init(self, sa_discovery): + raise NotImplementedError + + @abstractmethod + async def accept_connection(self, source, joining=False): + raise NotImplementedError + + @abstractmethod + def get_nodes_known(self, neighbors_too=False, neighbors_only=False): + raise NotImplementedError + + @abstractmethod + def get_actions(self): + raise NotImplementedError + + +def factory_sa_discovery(sa_discovery, additional, selector, model_handler, engine, verbose) -> ISADiscovery: + from nebula.core.situationalawareness.discovery.federationconnector import FederationConnector + + DISCOVERY = { + "nebula": FederationConnector, + } + sad = DISCOVERY.get(sa_discovery) + if sad: + return sad(additional, selector, model_handler, engine, verbose) + else: + raise Exception(f"SA Discovery service {sa_discovery} not found.") + + +def factory_sa_reasoner(sa_reasoner, config) -> ISAReasoner: + from nebula.core.situationalawareness.awareness.sareasoner import SAReasoner + + REASONER = { + "nebula": SAReasoner, + } + sar = REASONER.get(sa_reasoner) + if sar: + return sar(config) + else: + raise Exception(f"SA Reasoner service {sa_reasoner} not found.") + + +class SituationalAwareness: + def __init__(self, config, engine): + print_msg_box( + msg="Starting Situational Awareness module...", + indent=2, + title="Situational Awareness module", + ) + self._config = config + selector = self._config.participant["situational_awareness"]["sa_discovery"]["candidate_selector"] + selector = selector.lower() + model_handler = config.participant["situational_awareness"]["sa_discovery"]["model_handler"] + self._sad = factory_sa_discovery( + "nebula", + self._config.participant["mobility_args"]["additional_node"]["status"], + selector, + model_handler, + engine=engine, + verbose=config.participant["situational_awareness"]["sa_discovery"]["verbose"], + ) + self._sareasoner = factory_sa_reasoner( + "nebula", + self._config, + ) + + @property + def sad(self): + """SA Discovery""" + return self._sad + + @property + def sar(self): + """SA Reasoner""" + return self._sareasoner + + async def init(self): + await self.sad.init(self.sar) + await self.sar.init(self.sad) + + async def start_late_connection_process(self): + await self.sad.start_late_connection_process() + + async def get_trainning_info(self): + return await self.sad.get_trainning_info() diff --git a/nebula/core/utils/helper.py b/nebula/core/utils/helper.py index ab48b4013..e3d66f53f 100755 --- a/nebula/core/utils/helper.py +++ b/nebula/core/utils/helper.py @@ -89,7 +89,7 @@ def euclidean_metric( l2 = (l2 - l2.mean()) / std_l2 distance = torch.norm(l1 - l2, p=2) - + if similarity: norm_sum = torch.norm(l1, p=2) + torch.norm(l2, p=2) similarity_score = 1 - (distance / norm_sum if norm_sum != 0 else 0) @@ -237,7 +237,9 @@ def jaccard_metric( def normalise_layers(untrusted_params, trusted_params): - trusted_norms = dict([k, torch.norm(trusted_params[k].data.to("cpu").view(-1).float())] for k in trusted_params.keys()) + trusted_norms = dict( + [k, torch.norm(trusted_params[k].data.to("cpu").view(-1).float())] for k in trusted_params.keys() + ) normalised_params = copy.deepcopy(untrusted_params) diff --git a/nebula/core/utils/nebulalogger_tensorboard.py b/nebula/core/utils/nebulalogger_tensorboard.py index a8dc5943a..d7c4ce124 100755 --- a/nebula/core/utils/nebulalogger_tensorboard.py +++ b/nebula/core/utils/nebulalogger_tensorboard.py @@ -17,7 +17,7 @@ def get_step(self): def log_data(self, data, step=None): if step is None: step = self.get_step() - #logging.debug(f"Logging data for global step {step} | local step {self.local_step} | global step {self.global_step}") + # logging.debug(f"Logging data for global step {step} | local step {self.local_step} | global step {self.global_step}") try: super().log_metrics(data, step) except ValueError: @@ -29,7 +29,7 @@ def log_metrics(self, metrics, step=None): if step is None: self.local_step += 1 step = self.global_step + self.local_step - #logging.debug(f"Logging metrics for global step {step} | local step {self.local_step} | global step {self.global_step}") + # logging.debug(f"Logging metrics for global step {step} | local step {self.local_step} | global step {self.global_step}") if "epoch" in metrics: metrics.pop("epoch") try: @@ -61,4 +61,3 @@ def set_logger_config(self, logger_config): self.global_step = logger_config["global_step"] except Exception as e: logging.exception(f"Error setting logger config: {e}") - diff --git a/nebula/frontend/app.py b/nebula/frontend/app.py index 9fbf23727..3d1dfafec 100755 --- a/nebula/frontend/app.py +++ b/nebula/frontend/app.py @@ -20,6 +20,29 @@ class Settings: + """ + Configuration settings for the Nebula application, loaded from environment variables with sensible defaults. + + Attributes: + controller_host (str): Hostname or IP address of the Nebula controller service. + controller_port (int): Port on which the Nebula controller listens (default: 5000). + resources_threshold (float): Threshold for resource usage alerts (default: 0.0). + port (int): Port for the Nebula frontend service (default: 6060). + production (bool): Whether the application is running in production mode. + gpu_available (bool): Whether GPU resources are available. + advanced_analytics (bool): Whether advanced analytics features are enabled. + host_platform (str): Underlying host operating platform (e.g., 'unix'). + log_dir (str): Directory path where application logs are stored. + config_dir (str): Directory path for general configuration files. + cert_dir (str): Directory path for SSL/TLS certificates. + root_host_path (str): Root path on the host for volume mounting. + config_frontend_dir (str): Subdirectory for frontend-specific configuration (default: 'config'). + env_file (str): Path to the environment file to load additional variables (default: '.env'). + statistics_port (int): Port for the statistics/metrics endpoint (default: 8080). + PERMANENT_SESSION_LIFETIME (datetime.timedelta): Duration for session permanence (default: 60 minutes). + templates_dir (str): Directory name containing template files (default: 'templates'). + frontend_log (str): File path for the frontend log output. + """ controller_host: str = os.environ.get("NEBULA_CONTROLLER_HOST") controller_port: int = os.environ.get("NEBULA_CONTROLLER_PORT", 5000) resources_threshold: float = 0.0 @@ -63,7 +86,6 @@ class Settings: logging.info(f"Loading environment variables from {settings.env_file}") load_dotenv(settings.env_file, override=True) -from ansi2html import Ansi2HTMLConverter from fastapi import ( BackgroundTasks, Depends, @@ -81,7 +103,6 @@ class Settings: FileResponse, HTMLResponse, JSONResponse, - PlainTextResponse, RedirectResponse, StreamingResponse, ) @@ -90,31 +111,6 @@ class Settings: from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware -from nebula.frontend.database import ( - add_user, - check_scenario_with_role, - delete_user_from_db, - get_all_scenarios_and_check_completed, - get_notes, - get_running_scenario, - get_scenario_by_name, - get_user_by_scenario_name, - get_user_info, - initialize_databases, - list_nodes_by_scenario_name, - list_users, - remove_nodes_by_scenario_name, - remove_note, - remove_scenario_by_name, - save_notes, - scenario_set_all_status_to_finished, - scenario_set_status_to_finished, - scenario_update_record, - update_node_record, - update_user, - verify, - verify_hash_algorithm, -) from nebula.utils import DockerUtils, FileUtils logging.info(f"πŸš€ Starting Nebula Frontend on port {settings.port}") @@ -147,6 +143,28 @@ class Settings: class ConnectionManager: + """ + Manages WebSocket client connections, broadcasts messages to all connected clients, and retains a history of exchanged messages. + + Attributes: + historic_messages (dict[str, dict]): Stores each received or broadcast message keyed by timestamp (formatted as "%Y-%m-%d %H:%M:%S"). + active_connections (list[WebSocket]): List of currently open WebSocket connections. + + Methods: + async connect(websocket: WebSocket): + Accepts a new WebSocket connection, registers it, and broadcasts a control message indicating the new client count. + disconnect(websocket: WebSocket): + Removes the specified WebSocket from the active connections list if present. + add_message(message: str): + Parses the incoming JSON-formatted message string, timestamps it, and adds it to historic_messages. + async send_personal_message(message: str, websocket: WebSocket): + Sends a text message to a single WebSocket; on connection closure, cleans up the connection. + async broadcast(message: str): + Logs the message via add_message, then iterates through active_connections to send the message to all clients; + collects and removes any connections that have been closed or error out, logging exceptions as needed. + get_historic() -> dict[str, dict]: + Returns the full history of timestamped messages. + """ def __init__(self): self.historic_messages = {} self.active_connections: list[WebSocket] = [] @@ -181,7 +199,7 @@ async def send_personal_message(self, message: str, websocket: WebSocket): async def broadcast(self, message: str): self.add_message(message) disconnected_websockets = [] - + for connection in self.active_connections: try: await connection.send_text(message) @@ -189,9 +207,9 @@ async def broadcast(self, message: str): # Mark connection for removal disconnected_websockets.append(connection) except Exception as e: - logging.error(f"Error broadcasting message: {e}") + logging.exception(f"Error broadcasting message: {e}") disconnected_websockets.append(connection) - + # Remove disconnected websockets for websocket in disconnected_websockets: self.disconnect(websocket) @@ -205,6 +223,19 @@ def get_historic(self): @app.websocket("/platform/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: int): + """ + WebSocket endpoint for real-time chat at /platform/ws/{client_id}. + + Parameters: + websocket (WebSocket): The client’s WebSocket connection instance. + client_id (int): Unique identifier for the connecting client. + + Functionality: + - On connection: registers the client via manager.connect(websocket). + - Message loop: awaits incoming text frames, wraps each in a control message including the client_id, and broadcasts to all active clients using manager.broadcast(). + - On WebSocketDisconnect: deregisters the client via manager.disconnect(websocket) and broadcasts a β€œclient left” control message. + - Error handling: logs exceptions during broadcast or any unexpected WebSocket errors, ensuring the connection is cleaned up on failure. + """ await manager.connect(websocket) try: while True: @@ -216,16 +247,16 @@ async def websocket_endpoint(websocket: WebSocket, client_id: int): try: await manager.broadcast(json.dumps(message)) except Exception as e: - logging.error(f"Error broadcasting message: {e}") + logging.exception(f"Error broadcasting message: {e}") except WebSocketDisconnect: manager.disconnect(websocket) try: message = {"type": "control", "message": f"Client #{client_id} left the chat"} await manager.broadcast(json.dumps(message)) except Exception as e: - logging.error(f"Error broadcasting disconnect message: {e}") + logging.exception(f"Error broadcasting disconnect message: {e}") except Exception as e: - logging.error(f"WebSocket error: {e}") + logging.exception(f"WebSocket error: {e}") manager.disconnect(websocket) @@ -233,10 +264,30 @@ async def websocket_endpoint(websocket: WebSocket, client_id: int): def datetimeformat(value, format="%B %d, %Y %H:%M"): + """ + Formats a datetime string into a specified output format. + + Parameters: + value (str): Input datetime string in "%Y-%m-%d %H:%M:%S" format. + format (str): Desired output datetime format (default: "%B %d, %Y %H:%M"). + + Returns: + str: The datetime string formatted according to the provided format. + """ return datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S").strftime(format) def add_global_context(request: Request): + """ + Add global context variables for template rendering. + + Parameters: + request (Request): The incoming request object. + + Returns: + dict[str, bool]: + is_production: Flag indicating if the application is running in production mode. + """ return { "is_production": settings.production, } @@ -247,25 +298,31 @@ def add_global_context(request: Request): def get_session(request: Request) -> dict: - return request.session - - -def set_default_user(): - username = os.environ.get("NEBULA_DEFAULT_USER", "admin") - password = os.environ.get("NEBULA_DEFAULT_PASSWORD", "admin") - if not list_users(): - add_user(username, password, "admin") - if not verify_hash_algorithm(username): - update_user(username, password, "admin") + """ + Retrieve the session data associated with the incoming request. + Parameters: + request (Request): The HTTP request object containing session information. -@app.on_event("startup") -async def startup_event(): - await initialize_databases() - set_default_user() + Returns: + dict: The session data dictionary stored in the request. + """ + return request.session class UserData: + """ + Holds runtime state and synchronization events for user-specific scenario execution. + + Attributes: + nodes_registration (dict): Mapping of node identifiers to their registration data. + scenarios_list (list): Ordered list of scenario identifiers or objects to be executed. + scenarios_list_length (int): Total number of scenarios in scenarios_list. + scenarios_finished (int): Count of scenarios that have completed execution. + nodes_finished (list): List of node identifiers that have finished processing. + stop_all_scenarios_event (asyncio.Event): Event used to signal all scenarios should be halted. + finish_scenario_event (asyncio.Event): Event used to signal a single scenario has finished. + """ def __init__(self): self.nodes_registration = {} self.scenarios_list = [] @@ -280,9 +337,18 @@ def __init__(self): # Detect CTRL+C from parent process -def signal_handler(signal, frame): +async def signal_handler(signal, frame): + """ + Asynchronous signal handler for Ctrl+C (SIGINT) in the frontend. + + Logs the interrupt event, schedules all scenarios to be marked as finished by creating an asyncio task for `scenario_set_status_to_finished(all=True)`, and then exits the process. + + Parameters: + signal (int): The signal number received (e.g., signal.SIGINT). + frame (types.FrameType): The current stack frame at the moment the signal was handled. + """ logging.info("You pressed Ctrl+C [frontend]!") - scenario_set_all_status_to_finished() + asyncio.get_event_loop().create_task(scenario_set_status_to_finished(all=True)) sys.exit(0) @@ -291,6 +357,21 @@ def signal_handler(signal, frame): @app.exception_handler(StarletteHTTPException) async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): + """ + Custom HTTP exception handler for Starlette applications. + + Parameters: + request (Request): The incoming HTTP request object. + exc (StarletteHTTPException): The HTTP exception instance containing the status code to handle. + + Functionality: + - Builds a context dict with the request and its session. + - For specific HTTP status codes (401, 403, 404, 405, 413), returns a TemplateResponse rendering the corresponding error page and status. + - For all other status codes, delegates to the application's default exception handler. + + Returns: + Response: Either a TemplateResponse for the matched error code or the default exception handler’s response. + """ context = {"request": request, "session": request.session} if exc.status_code == status.HTTP_401_UNAUTHORIZED: return templates.TemplateResponse("401.html", context, status_code=exc.status_code) @@ -305,20 +386,480 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce return await request.app.default_exception_handler(request, exc) +async def controller_get(url): + """ + Fetch JSON data from a remote controller endpoint via asynchronous HTTP GET. + + Parameters: + url (str): The full URL of the controller API endpoint. + + Returns: + Any: Parsed JSON response when the HTTP status code is 200. + + Raises: + HTTPException: If the response status is not 200, raises with the response status code and an error detail. + """ + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + return await response.json() + else: + raise HTTPException(status_code=response.status, detail="Error fetching data") + + +async def controller_post(url, data=None): + """ + Asynchronously send a JSON payload via HTTP POST to a controller endpoint and parse the response. + + Parameters: + url (str): The full URL of the controller API endpoint. + data (Any, optional): JSON-serializable payload to include in the POST request (default: None). + + Returns: + Any: Parsed JSON response when the HTTP status code is 200. + + Raises: + HTTPException: If the response status is not 200, with the status code and an error detail. + """ + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data) as response: + if response.status == 200: + return await response.json() + else: + raise HTTPException(status_code=response.status, detail="Error posting data") + + +async def get_available_gpus(): + """ + Fetch the list of available GPUs from the controller service. + + Returns: + Any: Parsed JSON response containing available GPU information. + + Raises: + HTTPException: If the underlying HTTP request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/available_gpus" + return await controller_get(url) + + +async def get_least_memory_gpu(): + """ + Fetch the GPU with the least memory usage from the controller service. + + Returns: + Any: Parsed JSON response with details of the GPU having the least memory usage. + + Raises: + HTTPException: If the underlying HTTP request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/least_memory_gpu" + return await controller_get(url) + + +async def deploy_scenario(scenario_data, role, user): + """ + Deploy a new scenario on the controller with the given parameters. + + Parameters: + scenario_data (Any): Data payload describing the scenario to run. + role (str): Role identifier for the scenario execution. + user (str): Username initiating the deployment. + + Returns: + Any: Parsed JSON response confirming scenario deployment. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/run" + data = {"scenario_data": scenario_data, "role": role, "user": user} + return await controller_post(url, data) + + +async def get_scenarios(user, role): + """ + Retrieve all scenarios available for a specific user and role. + + Parameters: + user (str): Username to query scenarios for. + role (str): Role identifier to filter scenarios. + + Returns: + Any: Parsed JSON response listing available scenarios. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/{user}/{role}" + return await controller_get(url) + + +async def scenario_update_record(scenario_name, start_time, end_time, scenario, status, role, username): + """ + Update the record of a scenario’s execution status on the controller. + + Parameters: + scenario_name (str): Unique name of the scenario. + start_time (str): ISO-formatted start timestamp. + end_time (str): ISO-formatted end timestamp. + scenario (Any): Scenario payload or identifier. + status (str): New status value (e.g., 'running', 'finished'). + role (str): Role associated with the scenario. + username (str): User who ran or updated the scenario. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/update" + data = {"scenario_name": scenario_name, "start_time": start_time, "end_time": end_time, "scenario": scenario, "status": status, "role": role, "username": username} + await controller_post(url, data) + + +async def scenario_set_status_to_finished(scenario_name, all=False): + """ + Mark one or all scenarios as finished on the controller. + + Parameters: + scenario_name (str): Name of the scenario to update. + all (bool): If True, mark all scenarios as finished; otherwise only the named one. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/set_status_to_finished" + data = {"scenario_name": scenario_name, "all": all} + await controller_post(url, data) + + +async def remove_scenario_by_name(scenario_name): + """ + Remove a scenario by name from the controller’s records. + + Parameters: + scenario_name (str): Name of the scenario to remove. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/remove" + data = {"scenario_name": scenario_name} + await controller_post(url, data) + + +async def check_scenario_with_role(session, scenario_name): + """ + Check if a specific scenario is allowed for the session’s role. + + Parameters: + session (dict): Session data containing at least a 'role' key. + scenario_name (str): Name of the scenario to check. + + Returns: + bool: True if the scenario is allowed for the role, False otherwise. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = ( + f"http://{settings.controller_host}:{settings.controller_port}" + f"/scenarios/check?role={session['role']}&scenario_name={scenario_name}" + ) + check_data = await controller_get(url) + return check_data.get("allowed", False) + + +async def get_scenario_by_name(scenario_name): + """ + Fetch the details of a scenario by name from the controller. + + Parameters: + scenario_name (str): Name of the scenario to retrieve. + + Returns: + Any: Parsed JSON response with scenario details. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/{scenario_name}" + return await controller_get(url) + + +async def get_running_scenarios(get_all=False): + """ + Retrieve a list of currently running scenarios. + + Parameters: + get_all (bool): If True, include all running scenarios; if False, apply default filtering. + + Returns: + Any: Parsed JSON response listing running scenarios. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/scenarios/running?get_all={get_all}" + return await controller_get(url) + + +async def list_nodes_by_scenario_name(scenario_name): + """ + List all nodes associated with a given scenario. + + Parameters: + scenario_name (str): Name of the scenario to list nodes for. + + Returns: + Any: Parsed JSON response containing node details. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/nodes/{scenario_name}" + return await controller_get(url) + + +async def update_node_record(uid, idx, ip, port, role, neighbors, latitude, longitude, timestamp, federation, round_number, scenario_name, run_hash, malicious): + """ + Update the record of a node’s state on the controller. + + Parameters: + uid (str): Unique node identifier. + idx (int): Node index within the scenario. + ip (str): Node IP address. + port (int): Node port number. + role (str): Node role in the scenario. + neighbors (Any): Neighboring node references. + latitude (float): Node’s latitude coordinate. + longitude (float): Node’s longitude coordinate. + timestamp (str): ISO-formatted timestamp of the update. + federation (str): Federation identifier. + round_number (int): Current round number in the scenario. + scenario_name (str): Name of the scenario. + run_hash (str): Unique hash for this scenario run. + malicious (bool): Flag indicating if the node is malicious. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/nodes/update" + data = { + "node_uid": uid, + "node_idx": idx, + "node_ip": ip, + "node_port": port, + "node_role": role, + "node_neighbors": neighbors, + "node_latitude": latitude, + "node_longitude": longitude, + "node_timestamp": timestamp, + "node_federation": federation, + "node_round": round_number, + "node_scenario_name": scenario_name, + "node_run_hash": run_hash, + "malicious": malicious, + } + await controller_post(url, data) + + +async def remove_nodes_by_scenario_name(scenario_name): + """ + Remove all nodes associated with a scenario from the controller records. + + Parameters: + scenario_name (str): Name of the scenario whose nodes should be removed. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/nodes/remove" + data = {"scenario_name": scenario_name} + await controller_post(url, data) + + +async def get_notes(scenario_name): + """ + Fetch saved notes for a specific scenario. + + Parameters: + scenario_name (str): Name of the scenario to retrieve notes for. + + Returns: + Any: Parsed JSON response containing the notes. + + Raises: + HTTPException: If the underlying HTTP GET request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/notes/{scenario_name}" + return await controller_get(url) + + +async def save_notes(scenario_name, notes): + """ + Save or update notes for a specific scenario on the controller. + + Parameters: + scenario_name (str): Name of the scenario to attach notes to. + notes (Any): Content of the notes to be saved. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/notes/update" + data = {"scenario_name": scenario_name, "notes": notes} + await controller_post(url, data) + + +async def remove_note(scenario_name): + """ + Remove notes for a specific scenario from the controller. + + Parameters: + scenario_name (str): Name of the scenario whose notes should be removed. + + Raises: + HTTPException: If the underlying HTTP POST request fails. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/notes/remove" + data = {"scenario_name": scenario_name} + await controller_post(url, data) + + +async def list_users(allinfo=True): + """ + Retrieves the list of users by calling the controller endpoint. + + Parameters: + - all_info (bool): If True, retrieves detailed information for each user. + + Returns: + - A list of users, as provided by the controller. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/list?all_info={allinfo}" + data = await controller_get(url) + user_list = data["users"] + + return user_list + + +async def get_user_by_scenario_name(scenario_name): + """ + Fetch user data for a given scenario from the controller. + + Parameters: + - scenario_name (str): The name of the scenario whose user data to retrieve. + + Returns: + - dict: The user data associated with the specified scenario. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/{scenario_name}" + return await controller_get(url) + + +async def add_user(user, password, role): + """ + Create a new user via the controller endpoint. + + Parameters: + - user (str): The username for the new user. + - password (str): The password for the new user. + - role (str): The role assigned to the new user. + + Returns: + - None + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/add" + data = {"user": user, "password": password, "role": role} + await controller_post(url, data) + + +async def update_user(user, password, role): + """ + Update an existing user's credentials and role via the controller endpoint. + + Parameters: + - user (str): The username of the user to update. + - password (str): The new password for the user. + - role (str): The new role to assign to the user. + + Returns: + - None + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/update" + data = {"user": user, "password": password, "role": role} + await controller_post(url, data) + + +async def delete_user(user): + """ + Delete an existing user via the controller endpoint. + + Parameters: + - user (str): The username of the user to delete. + + Returns: + - None + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/delete" + data = {"user": user} + await controller_post(url, data) + + +async def verify_user(user, password): + """ + Verify a user's credentials against the controller. + + Parameters: + - user (str): The username to verify. + - password (str): The password to verify for the user. + + Returns: + - dict: The verification result from the controller, typically including authentication status. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/user/verify" + data = {"user": user, "password": password} + return await controller_post(url, data) + + @app.get("/", response_class=HTMLResponse) async def index(): + """ + Handle root path by redirecting to the platform home page. + + Returns: + RedirectResponse: Redirects client to the '/platform' endpoint. + """ return RedirectResponse(url="/platform") @app.get("/platform", response_class=HTMLResponse) @app.get("/platform/", response_class=HTMLResponse) async def nebula_home(request: Request): + """ + Render the Nebula platform home page. + + Parameters: + request (Request): FastAPI request object. + + Returns: + HTMLResponse: Rendered 'index.html' template with alerts context. + """ alerts = [] return templates.TemplateResponse("index.html", {"request": request, "alerts": alerts}) @app.get("/platform/historic") async def nebula_ws_historic(session: dict = Depends(get_session)): + """ + Retrieve historical data for admin users. + + Parameters: + session (dict): Session data extracted via dependency. + + Returns: + JSONResponse: Historical data if available, otherwise an error message. + """ if session.get("role") == "admin": historic = manager.get_historic() if historic: @@ -330,6 +871,20 @@ async def nebula_ws_historic(session: dict = Depends(get_session)): @app.get("/platform/dashboard/{scenario_name}/private", response_class=HTMLResponse) async def nebula_dashboard_private(request: Request, scenario_name: str, session: dict = Depends(get_session)): + """ + Render the private scenario dashboard for authenticated users. + + Parameters: + request (Request): FastAPI request object. + scenario_name (str): Name of the scenario to display. + session (dict): Session data extracted via dependency. + + Returns: + HTMLResponse: Rendered 'private.html' template with scenario context. + + Raises: + HTTPException: 401 Unauthorized if the user is not authenticated. + """ if "user" in session: return templates.TemplateResponse("private.html", {"request": request, "scenario_name": scenario_name}) else: @@ -338,12 +893,26 @@ async def nebula_dashboard_private(request: Request, scenario_name: str, session @app.get("/platform/admin", response_class=HTMLResponse) async def nebula_admin(request: Request, session: dict = Depends(get_session)): + """ + Render the admin interface showing a list of users for admin role. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + HTMLResponse: Rendered 'admin.html' template with user table context. + + Raises: + HTTPException: 401 Unauthorized if the user is not an admin. + """ if session.get("role") == "admin": - user_list = list_users(all_info=True) + user_list = await list_users() + user_table = zip( range(1, len(user_list) + 1), - [user[0] for user in user_list], - [user[2] for user in user_list], + [user["user"] for user in user_list], + [user["role"] for user in user_list], strict=False, ) return templates.TemplateResponse("admin.html", {"request": request, "users": user_table}) @@ -353,11 +922,22 @@ async def nebula_admin(request: Request, session: dict = Depends(get_session)): @app.post("/platform/dashboard/{scenario_name}/save_note") async def save_note_for_scenario(scenario_name: str, request: Request, session: dict = Depends(get_session)): + """ + Save notes for a specific scenario for authenticated users. + + Parameters: + scenario_name (str): Name of the scenario. + request (Request): FastAPI request object containing JSON payload. + session (dict): Session data extracted via dependency. + + Returns: + JSONResponse: {"status": "success"} on success, or error message on failure. + """ if "user" in session: data = await request.json() notes = data["notes"] try: - save_notes(scenario_name, notes) + await save_notes(scenario_name, notes) return JSONResponse({"status": "success"}) except Exception as e: logging.exception(e) @@ -371,9 +951,18 @@ async def save_note_for_scenario(scenario_name: str, request: Request, session: @app.get("/platform/dashboard/{scenario_name}/notes") async def get_notes_for_scenario(scenario_name: str): - notes_record = get_notes(scenario_name) + """ + Retrieve saved notes for a specific scenario. + + Parameters: + scenario_name (str): Name of the scenario to retrieve notes for. + + Returns: + JSONResponse: {"status": "success", "notes": } if found, otherwise an error message. + """ + notes_record = await get_notes(scenario_name) if notes_record: - notes_data = dict(zip(notes_record.keys(), notes_record, strict=False)) + notes_data = dict(notes_record) return JSONResponse({"status": "success", "notes": notes_data["scenario_notes"]}) else: return JSONResponse({"status": "error", "message": "Notes not found for the specified scenario"}) @@ -381,6 +970,15 @@ async def get_notes_for_scenario(scenario_name: str): @app.get("/platform/dashboard/{scenario_name}/config") async def get_config_for_scenario(scenario_name: str): + """ + Load configuration for a specific scenario from the filesystem. + + Parameters: + scenario_name (str): Name of the scenario to load configuration for. + + Returns: + JSONResponse: {"status": "success", "config": } if successful, or error message if file not found or invalid JSON. + """ json_path = os.path.join(os.environ.get("NEBULA_CONFIG_DIR"), scenario_name, "scenario.json") try: @@ -405,34 +1003,64 @@ async def nebula_login( user: str = Form(...), password: str = Form(...), ): - user_submitted = user.upper() - if (user_submitted in list_users()) and verify(user_submitted, password): - user_info = get_user_info(user_submitted) - session["user"] = user_submitted - session["role"] = user_info[2] - return JSONResponse({"message": "Login successful"}, status_code=200) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + """ + Authenticate a user and initialize session data. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + user (str): Username provided in form data. + password (str): Password provided in form data. + + Returns: + JSONResponse: {"message": "Login successful"} with HTTP 200 status on success. + """ + data = await verify_user(user, password) + session["user"] = data.get("user") + session["role"] = data.get("role") + return JSONResponse({"message": "Login successful"}, status_code=200) @app.get("/platform/logout") async def nebula_logout(request: Request, session: dict = Depends(get_session)): + """ + Log out the authenticated user and redirect to the platform home. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects client to the '/platform' endpoint. + """ session.pop("user", None) return RedirectResponse(url="/platform") @app.get("/platform/user/delete/{user}/") async def nebula_delete_user(user: str, request: Request, session: dict = Depends(get_session)): + """ + Delete a specified user account via admin privileges, preventing deletion of 'ADMIN' or self. + + Parameters: + user (str): Username of the account to delete. + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects client to '/platform/admin' on success. + + Raises: + HTTPException: 403 Forbidden if attempting to delete 'ADMIN' or the current user. + """ if session.get("role") == "admin": if user == "ADMIN": # ADMIN account can't be deleted. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if user == session["user"]: # Current user can't delete himself. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - delete_user_from_db(user) + await delete_user(user) return RedirectResponse(url="/platform/admin") - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @app.post("/platform/user/add") @@ -443,15 +1071,35 @@ async def nebula_add_user( password: str = Form(...), role: str = Form(...), ): - if session.get("role") == "admin": # only Admin should be able to add user. - user_list = list_users(all_info=True) - if user.upper() in user_list or " " in user or "'" in user or '"' in user: - return RedirectResponse(url="/platform/admin", status_code=status.HTTP_303_SEE_OTHER) - else: - add_user(user, password, role) - return RedirectResponse(url="/platform/admin", status_code=status.HTTP_303_SEE_OTHER) - else: + """ + Add a new user to the system via form submission, available only to admin users, with basic username validation. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + user (str): Username provided in form data. + password (str): Password provided in form data. + role (str): Role provided in form data. + + Returns: + RedirectResponse: Redirects client to '/platform/admin' with status 303 on success. + + Raises: + HTTPException: 401 Unauthorized if the current user is not an admin. + """ + # Only admin users can add new users. + if session.get("role") != "admin": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + # Basic validation on the user value before calling the controller. + user_list = await list_users() + + if user.upper() in user_list or " " in user or "'" in user or '"' in user: + return RedirectResponse(url="/platform/admin", status_code=status.HTTP_303_SEE_OTHER) + + # Call the controller's endpoint to add the user. + await add_user(user, password, role) + return RedirectResponse(url="/platform/admin", status_code=status.HTTP_303_SEE_OTHER) @app.post("/platform/user/update") @@ -462,18 +1110,41 @@ async def nebula_update_user( password: str = Form(...), role: str = Form(...), ): + """ + Update an existing user's credentials and role via form submission, accessible only to admin users. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + user (str): Username provided in form data. + password (str): New password provided in form data. + role (str): New role provided in form data. + + Returns: + RedirectResponse: Redirects client to '/platform/admin' on success, or to '/platform' if unauthorized. + """ if "user" not in session or session["role"] != "admin": return RedirectResponse(url="/platform", status_code=status.HTTP_302_FOUND) - update_user(user, password, role) + + await update_user(user, password, role) return RedirectResponse(url="/platform/admin", status_code=status.HTTP_302_FOUND) @app.get("/platform/api/dashboard/runningscenario", response_class=JSONResponse) async def nebula_dashboard_runningscenario(session: dict = Depends(get_session)): + """ + Get information about currently running scenario(s) for the authenticated user or admin. + + Parameters: + session (dict): Session data extracted via dependency. + + Returns: + JSONResponse: JSON object containing running scenario details and status, or {"scenario_status": "not running"}. + """ if session.get("role") == "admin": - scenario_running = get_running_scenario() + scenario_running = await get_running_scenarios() elif "user" in session: - scenario_running = get_running_scenario(session["user"]) + scenario_running = await get_running_scenarios(session["user"]) if scenario_running: scenario_running_as_dict = dict(scenario_running) scenario_running_as_dict["scenario_status"] = "running" @@ -483,31 +1154,14 @@ async def nebula_dashboard_runningscenario(session: dict = Depends(get_session)) async def get_host_resources(): - url = f"http://{settings.controller_host}:{settings.controller_port}/resources" - async with aiohttp.ClientSession() as session, session.get(url) as response: - if response.status == 200: - try: - return await response.json() - except Exception as e: - return {"error": f"Failed to parse JSON: {e}"} - else: - return None - + """ + Retrieve host resource usage data from the controller endpoint. -async def get_available_gpus(): - url = f"http://{settings.controller_host}:{settings.controller_port}/available_gpus" - async with aiohttp.ClientSession() as session, session.get(url) as response: - if response.status == 200: - try: - return await response.json() - except Exception as e: - return {"error": f"Failed to parse JSON: {e}"} - else: - return None - - -async def get_least_memory_gpu(): - url = f"http://{settings.controller_host}:{settings.controller_port}/least_memory_gpu" + Returns: + dict: Parsed JSON resource metrics on success, or {'error': } on parse failure. + None: If the HTTP response status is not 200. + """ + url = f"http://{settings.controller_host}:{settings.controller_port}/resources" async with aiohttp.ClientSession() as session, session.get(url) as response: if response.status == 200: try: @@ -519,6 +1173,12 @@ async def get_least_memory_gpu(): async def check_enough_resources(): + """ + Check if the host's memory usage is below the configured threshold. + + Returns: + bool: True if sufficient resources are available (or threshold is 0.0), False otherwise. + """ resources = await get_host_resources() mem_percent = resources.get("memory_percent") @@ -533,6 +1193,12 @@ async def check_enough_resources(): async def wait_for_enough_ram(): + """ + Asynchronously wait until the host's memory usage falls below 80% of its initial measurement. + + Returns: + None + """ resources = await get_host_resources() initial_ram = resources.get("memory_percent") @@ -549,10 +1215,16 @@ async def wait_for_enough_ram(): async def monitor_resources(): + """ + Continuously monitor host resources and, if usage exceeds the threshold, stop the last running scenario after broadcasting a message, then wait for resources to recover. + + Returns: + None + """ while True: enough_resources = await check_enough_resources() if not enough_resources: - running_scenarios = get_running_scenario(get_all=True) + running_scenarios = await get_running_scenarios(get_all=True) if running_scenarios: last_running_scenario = running_scenarios.pop() running_scenario_as_dict = dict(last_running_scenario) @@ -585,14 +1257,24 @@ async def monitor_resources(): @app.get("/platform/api/dashboard", response_class=JSONResponse) @app.get("/platform/dashboard", response_class=HTMLResponse) async def nebula_dashboard(request: Request, session: dict = Depends(get_session)): + """ + Render or return the dashboard view or API data for the current user. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + TemplateResponse: Rendered 'dashboard.html' for HTML endpoint. + JSONResponse: List of scenario dictionaries or status message for API endpoint. + + Raises: + HTTPException: 401 Unauthorized for invalid path access. + """ if "user" in session: - scenarios = get_all_scenarios_and_check_completed( - username=session["user"], role=session["role"] - ) # Get all scenarios after checking if they are completed - if session.get("role") == "admin": - scenario_running = get_running_scenario() - else: - scenario_running = get_running_scenario(username=session["user"]) + response = await get_scenarios(session["user"], session["role"]) + scenarios = response.get("scenarios") + scenario_running = response.get("scenario_running") if session["user"] not in user_data_store: user_data_store[session["user"]] = UserData() @@ -604,7 +1286,7 @@ async def nebula_dashboard(request: Request, session: dict = Depends(get_session bool_completed = False if scenario_running: - bool_completed = scenario_running[6] == "completed" + bool_completed = scenario_running["status"] == "completed" if scenarios: if request.url.path == "/platform/dashboard": return templates.TemplateResponse( @@ -644,16 +1326,31 @@ async def nebula_dashboard(request: Request, session: dict = Depends(get_session @app.get("/platform/api/dashboard/{scenario_name}/monitor", response_class=JSONResponse) @app.get("/platform/dashboard/{scenario_name}/monitor", response_class=HTMLResponse) async def nebula_dashboard_monitor(scenario_name: str, request: Request, session: dict = Depends(get_session)): - scenario = get_scenario_by_name(scenario_name) + """ + Display or return monitoring information for a specific scenario, including node statuses. + + Parameters: + scenario_name (str): Name of the scenario to monitor. + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + TemplateResponse: Rendered 'monitor.html' for HTML endpoint. + JSONResponse: JSON object containing scenario status, node list, and scenario metadata. + + Raises: + HTTPException: 401 Unauthorized for invalid path access. + """ + scenario = await get_scenario_by_name(scenario_name) if scenario: - nodes_list = list_nodes_by_scenario_name(scenario_name) + nodes_list = await list_nodes_by_scenario_name(scenario_name) if nodes_list: formatted_nodes = [] for node in nodes_list: # Calculate initial status based on timestamp timestamp = datetime.datetime.strptime(node[8], "%Y-%m-%d %H:%M:%S.%f") is_online = (datetime.datetime.now() - timestamp) <= datetime.timedelta(seconds=25) - + formatted_nodes.append({ "uid": node[0], "idx": node[1], @@ -669,7 +1366,7 @@ async def nebula_dashboard_monitor(scenario_name: str, request: Request, session "scenario_name": node[11], "hash": node[12], "malicious": node[13], - "status": is_online + "status": is_online, }) # For HTML response, return the template with basic data @@ -690,8 +1387,8 @@ async def nebula_dashboard_monitor(scenario_name: str, request: Request, session "scenario_status": scenario[5], "nodes": formatted_nodes, "scenario_name": scenario[0], - "scenario_title": scenario[3], - "scenario_description": scenario[4], + "title": scenario[3], + "description": scenario[4], }) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -713,8 +1410,8 @@ async def nebula_dashboard_monitor(scenario_name: str, request: Request, session "scenario_status": scenario[5], "nodes": [], "scenario_name": scenario[0], - "scenario_title": scenario[3], - "scenario_description": scenario[4], + "title": scenario[3], + "description": scenario[4], }) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -738,6 +1435,18 @@ async def nebula_dashboard_monitor(scenario_name: str, request: Request, session def update_topology(scenario_name, nodes_list, nodes_config): + """ + Update the network topology for a given scenario by constructing an adjacency matrix, + applying node configurations, and generating a topology image. + + Parameters: + scenario_name (str): Name of the scenario. + nodes_list (list): List of node tuples containing network and node attributes. + nodes_config (dict): Configuration settings for each node. + + Returns: + None + """ import numpy as np nodes = [] @@ -763,29 +1472,22 @@ def update_topology(scenario_name, nodes_list, nodes_config): @app.post("/platform/dashboard/{scenario_name}/node/update") async def nebula_update_node(scenario_name: str, request: Request): + """ + Process a node update request for a scenario and broadcast the updated node information. + + Parameters: + scenario_name (str): Name of the scenario. + request (Request): FastAPI request object containing a JSON payload with node update data. + + Returns: + JSONResponse: {"message": "Node updated", "status": "success"} on success. + + Raises: + HTTPException: 400 Bad Request if the content type is not application/json. + """ if request.method == "POST": if request.headers.get("content-type") == "application/json": config = await request.json() - timestamp = datetime.datetime.now() - # Update the node in database - await update_node_record( - str(config["device_args"]["uid"]), - str(config["device_args"]["idx"]), - str(config["network_args"]["ip"]), - str(config["network_args"]["port"]), - str(config["device_args"]["role"]), - str(config["network_args"]["neighbors"]), - str(config["mobility_args"]["latitude"]), - str(config["mobility_args"]["longitude"]), - str(timestamp), - str(config["scenario_args"]["federation"]), - str(config["federation_args"]["round"]), - str(config["scenario_args"]["name"]), - str(config["tracking_args"]["run_hash"]), - str(config["device_args"]["malicious"]), - ) - - neighbors_distance = config["mobility_args"]["neighbors_distance"] node_update = { "type": "node_update", @@ -799,12 +1501,12 @@ async def nebula_update_node(scenario_name: str, request: Request): "neighbors": config["network_args"]["neighbors"], "latitude": config["mobility_args"]["latitude"], "longitude": config["mobility_args"]["longitude"], - "timestamp": str(timestamp), + "timestamp": config["timestamp"], "federation": config["scenario_args"]["federation"], "round": config["federation_args"]["round"], "name": config["scenario_args"]["name"], "status": True, - "neighbors_distance": neighbors_distance, + "neighbors_distance": config["mobility_args"]["neighbors_distance"], "malicious": str(config["device_args"]["malicious"]) } @@ -818,119 +1520,67 @@ async def nebula_update_node(scenario_name: str, request: Request): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) -@app.post("/platform/dashboard/{scenario_name}/node/register") -async def nebula_register_node(scenario_name: str, request: Request, session: dict = Depends(get_session)): - user_data = user_data_store[session["user"]] - - if request.headers.get("content-type") == "application/json": - data = await request.json() - node = data["node"] - logging.info(f"Registering node {node} for scenario {scenario_name}") - async with user_data.nodes_registration[scenario_name]["condition"]: - user_data.nodes_registration[scenario_name]["nodes"].add(node) - logging.info(f"Node {node} registered") - if ( - len(user_data.nodes_registration[scenario_name]["nodes"]) - == user_data.nodes_registration[scenario_name]["n_nodes"] - ): - user_data.nodes_registration[scenario_name]["condition"].notify_all() - logging.info("All nodes registered") - - return JSONResponse({"message": "Node registered", "status": "success"}, status_code=200) - else: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) - - -@app.get("/platform/dashboard/scenarios/node/list") -async def nebula_list_all_scenarios(session: dict = Depends(get_session)): - user_data = user_data_store[session["user"]] - - if "user" not in session or session["role"] not in ["admin", "user"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") - - scenarios = {} - for scenario_name, scenario_info in user_data.nodes_registration.items(): - scenarios[scenario_name] = list(scenario_info["nodes"]) - - if not scenarios: - return JSONResponse({"message": "No scenarios found", "status": "error"}, status_code=404) - - return JSONResponse({"scenarios": scenarios, "status": "success"}, status_code=200) - - -@app.get("/platform/dashboard/scenarios/node/erase") -async def nebula_erase_all_nodes(session: dict = Depends(get_session)): - user_data = user_data_store[session["user"]] - - if "user" not in session or session["role"] not in ["admin", "user"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") - - user_data.nodes_registration.clear() - return JSONResponse({"message": "All nodes erased", "status": "success"}, status_code=200) - - -@app.get("/platform/dashboard/{scenario_name}/node/wait") -async def nebula_wait_nodes(scenario_name: str, session: dict = Depends(get_session)): - user_data = user_data_store[session["user"]] - - if scenario_name not in user_data.nodes_registration: - return JSONResponse({"message": "Scenario not found", "status": "error"}, status_code=404) +# Recieve a stopped node +@app.post("/platform/dashboard/{scenario_name}/node/done") +async def node_stopped(scenario_name: str, request: Request): + """ + Handle notification that a node has finished its task; mark the node as finished, + stop the scenario if all nodes are done, and signal scenario completion. - async with user_data.nodes_registration[scenario_name]["condition"]: - while ( - len(user_data.nodes_registration[scenario_name]["nodes"]) - < user_data.nodes_registration[scenario_name]["n_nodes"] - ): - await user_data.nodes_registration[scenario_name]["condition"].wait() - return JSONResponse({"message": "All nodes registered", "status": "success"}, status_code=200) + Parameters: + scenario_name (str): Name of the scenario. + request (Request): FastAPI request object containing a JSON payload with the finished node index. + Returns: + JSONResponse: Message indicating node completion status or scenario completion. -@app.get("/platform/dashboard/{scenario_name}/node/{id}/infolog") -async def nebula_monitor_log(scenario_name: str, id: str): - logs = FileUtils.check_path(settings.log_dir, os.path.join(scenario_name, f"participant_{id}.log")) - if os.path.exists(logs): - return FileResponse(logs, media_type="text/plain", filename=f"participant_{id}.log") - else: - raise HTTPException(status_code=404, detail="Log file not found") + Raises: + HTTPException: 400 Bad Request if the content type is not application/json. + """ + user = await get_user_by_scenario_name(scenario_name) + user_data = user_data_store[user] + if request.headers.get("content-type") == "application/json": + data = await request.json() + user_data.nodes_finished.append(data["idx"]) + nodes_list = await list_nodes_by_scenario_name(scenario_name) + finished = True + # Check if all the nodes of the scenario have finished the experiment + for node in nodes_list: + if str(node[1]) not in map(str, user_data.nodes_finished): + finished = False -@app.get( - "/platform/dashboard/{scenario_name}/node/{id}/infolog/{number}", - response_class=PlainTextResponse, -) -async def nebula_monitor_log_x(scenario_name: str, id: str, number: int): - logs = FileUtils.check_path(settings.log_dir, os.path.join(scenario_name, f"participant_{id}.log")) - if os.path.exists(logs): - with open(logs) as f: - lines = f.readlines()[-number:] - lines = "".join(lines) - converter = Ansi2HTMLConverter() - html_text = converter.convert(lines, full=False) - return Response(content=html_text, media_type="text/plain") + if finished: + await stop_scenario(scenario_name, user) + user_data.nodes_finished.clear() + user_data.finish_scenario_event.set() + return JSONResponse( + status_code=200, + content={"message": "All nodes finished, scenario marked as completed."}, + ) + else: + return JSONResponse( + status_code=200, + content={"message": "Node marked as finished, waiting for other nodes."}, + ) else: - return Response(content="No logs available", media_type="text/plain") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) -@app.get("/platform/dashboard/{scenario_name}/node/{id}/debuglog") -async def nebula_monitor_log_debug(scenario_name: str, id: str): - logs = FileUtils.check_path(settings.log_dir, os.path.join(scenario_name, f"participant_{id}_debug.log")) - if os.path.exists(logs): - return FileResponse(logs, media_type="text/plain", filename=f"participant_{id}_debug.log") - else: - raise HTTPException(status_code=404, detail="Log file not found") - +@app.get("/platform/dashboard/{scenario_name}/topology/image/") +async def nebula_monitor_image(scenario_name: str): + """ + Serve the topology image for a given scenario if available. -@app.get("/platform/dashboard/{scenario_name}/node/{id}/errorlog") -async def nebula_monitor_log_error(scenario_name: str, id: str): - logs = FileUtils.check_path(settings.log_dir, os.path.join(scenario_name, f"participant_{id}_error.log")) - if os.path.exists(logs): - return FileResponse(logs, media_type="text/plain", filename=f"participant_{id}_error.log") - else: - raise HTTPException(status_code=404, detail="Log file not found") + Parameters: + scenario_name (str): Name of the scenario. + Returns: + FileResponse: The topology.png image for the scenario. -@app.get("/platform/dashboard/{scenario_name}/topology/image/") -async def nebula_monitor_image(scenario_name: str): + Raises: + HTTPException: 404 Not Found if the topology image does not exist. + """ topology_image = FileUtils.check_path(settings.config_dir, os.path.join(scenario_name, "topology.png")) if os.path.exists(topology_image): return FileResponse(topology_image, media_type="image/png", filename=f"{scenario_name}_topology.png") @@ -938,8 +1588,19 @@ async def nebula_monitor_image(scenario_name: str): raise HTTPException(status_code=404, detail="Topology image not found") -def stop_scenario(scenario_name, user): - from nebula.scenarios import ScenarioManagement +async def stop_scenario(scenario_name, user): + """ + Stop a running scenario by terminating participants, cleaning up Docker resources, + updating scenario status, and generating scenario statistics. + + Parameters: + scenario_name (str): Name of the scenario to stop. + user (str): Username associated with the scenario. + + Returns: + None + """ + from nebula.controller.scenarios import ScenarioManagement ScenarioManagement.stop_participants(scenario_name) DockerUtils.remove_containers_by_prefix(f"{os.environ.get('NEBULA_CONTROLLER_NAME')}_{user}-participant") @@ -947,20 +1608,12 @@ def stop_scenario(scenario_name, user): f"{(os.environ.get('NEBULA_CONTROLLER_NAME'))}_{str(user).lower()}-nebula-net-scenario" ) ScenarioManagement.stop_blockchain() - scenario_set_status_to_finished(scenario_name) + await scenario_set_status_to_finished(scenario_name) # Generate statistics for the scenario path = FileUtils.check_path(settings.log_dir, scenario_name) ScenarioManagement.generate_statistics(path) -def stop_all_scenarios(): - from nebula.scenarios import ScenarioManagement - - ScenarioManagement.stop_participants() - ScenarioManagement.stop_blockchain() - scenario_set_all_status_to_finished() - - @app.get("/platform/dashboard/{scenario_name}/stop/{stop_all}") async def nebula_stop_scenario( scenario_name: str, @@ -968,31 +1621,53 @@ async def nebula_stop_scenario( request: Request, session: dict = Depends(get_session), ): + """ + Stop one or all scenarios for the current user and redirect to the dashboard. + + Parameters: + scenario_name (str): Name of the scenario to stop. + stop_all (bool): If True, stop all scenarios; otherwise stop only the specified one. + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects to the '/platform/dashboard' endpoint. + + Raises: + HTTPException: 401 Unauthorized if the user is not authenticated or lacks permission. + """ if "user" in session: - user = get_user_by_scenario_name(scenario_name) + user = await get_user_by_scenario_name(scenario_name) user_data = user_data_store[user] if session["role"] == "demo": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - # elif session["role"] == "user": - # if not check_scenario_with_role(session["role"], scenario_name): - # raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) if stop_all: user_data.stop_all_scenarios_event.set() user_data.scenarios_list_length = 0 user_data.scenarios_finished = 0 - stop_scenario(scenario_name, user) + await stop_scenario(scenario_name, user) else: user_data.finish_scenario_event.set() user_data.scenarios_list_length -= 1 - stop_scenario(scenario_name, user) + await stop_scenario(scenario_name, user) return RedirectResponse(url="/platform/dashboard") else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) -def remove_scenario(scenario_name=None, user=None): - from nebula.scenarios import ScenarioManagement +async def remove_scenario(scenario_name=None, user=None): + """ + Remove all data and resources associated with a scenario, including nodes, notes, and files. + + Parameters: + scenario_name (str, optional): Name of the scenario to remove. + user (str, optional): Username associated with the scenario. + + Returns: + None + """ + from nebula.controller.scenarios import ScenarioManagement user_data = user_data_store[user] @@ -1000,9 +1675,9 @@ def remove_scenario(scenario_name=None, user=None): logging.info("Advanced analytics enabled") # Remove registered nodes and conditions user_data.nodes_registration.pop(scenario_name, None) - remove_nodes_by_scenario_name(scenario_name) - remove_scenario_by_name(scenario_name) - remove_note(scenario_name) + await remove_nodes_by_scenario_name(scenario_name) + await remove_scenario_by_name(scenario_name) + await remove_note(scenario_name) ScenarioManagement.remove_files_by_scenario(scenario_name) @@ -1010,13 +1685,28 @@ def remove_scenario(scenario_name=None, user=None): async def nebula_relaunch_scenario( scenario_name: str, background_tasks: BackgroundTasks, session: dict = Depends(get_session) ): + """ + Relaunch a previously run scenario by loading its configuration, enqueuing it, + and starting execution in the background. + + Parameters: + scenario_name (str): Name of the scenario to relaunch. + background_tasks (BackgroundTasks): FastAPI BackgroundTasks instance for deferred execution. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects to the '/platform/dashboard' endpoint. + + Raises: + HTTPException: 401 Unauthorized if the user is not authenticated or lacks permission. + """ user_data = user_data_store[session["user"]] if "user" in session: if session["role"] == "demo": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) elif session["role"] == "user": - if not check_scenario_with_role(session["role"], scenario_name): + if not await check_scenario_with_role(session["role"], scenario_name): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) scenario_path = FileUtils.check_path(settings.config_dir, os.path.join(scenario_name, "scenario.json")) @@ -1040,13 +1730,26 @@ async def nebula_relaunch_scenario( @app.get("/platform/dashboard/{scenario_name}/remove") async def nebula_remove_scenario(scenario_name: str, session: dict = Depends(get_session)): + """ + Remove a scenario for the authenticated user and redirect back to the dashboard. + + Parameters: + scenario_name (str): Name of the scenario to remove. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects to the '/platform/dashboard' endpoint. + + Raises: + HTTPException: 401 Unauthorized if the user is not authenticated or lacks permission. + """ if "user" in session: if session["role"] == "demo": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) elif session["role"] == "user": - if not check_scenario_with_role(session["role"], scenario_name): + if not await check_scenario_with_role(session["role"], scenario_name): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - remove_scenario(scenario_name, session["user"]) + await remove_scenario(scenario_name, session["user"]) return RedirectResponse(url="/platform/dashboard") else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -1059,82 +1762,127 @@ async def nebula_remove_scenario(scenario_name: str, session: dict = Depends(get # TENSORBOARD START - @app.get("/platform/dashboard/statistics/", response_class=HTMLResponse) - @app.get("/platform/dashboard/{scenario_name}/statistics/", response_class=HTMLResponse) - async def nebula_dashboard_statistics(request: Request, scenario_name: str = None): - statistics_url = "/platform/statistics/" - if scenario_name is not None: - statistics_url += f"?smoothing=0&runFilter={scenario_name}" - - return templates.TemplateResponse("statistics.html", {"request": request, "statistics_url": statistics_url}) - - @app.api_route("/platform/statistics/", methods=["GET", "POST"]) - @app.api_route("/platform/statistics/{path:path}", methods=["GET", "POST"]) - async def statistics_proxy(request: Request, path: str = None, session: dict = Depends(get_session)): - if "user" in session: - query_string = urlencode(request.query_params) - - url = "http://localhost:8080" - tensorboard_url = f"{url}{('/' + path) if path else ''}" + ("?" + query_string if query_string else "") - - headers = {key: value for key, value in request.headers.items() if key.lower() != "host"} - - response = requests.request( - method=request.method, - url=tensorboard_url, - headers=headers, - data=await request.body(), - cookies=request.cookies, - allow_redirects=False, +@app.get("/platform/dashboard/statistics/", response_class=HTMLResponse) +@app.get("/platform/dashboard/{scenario_name}/statistics/", response_class=HTMLResponse) +async def nebula_dashboard_statistics(request: Request, scenario_name: str = None): + """ + Render the TensorBoard statistics page for all experiments or filter by scenario. + + Parameters: + request (Request): FastAPI request object. + scenario_name (str, optional): Scenario name to filter statistics by; defaults to None. + + Returns: + TemplateResponse: Rendered 'statistics.html' with the appropriate URL parameter for TensorBoard. + """ + statistics_url = "/platform/statistics/" + if scenario_name is not None: + statistics_url += f"?smoothing=0&runFilter={scenario_name}" + + return templates.TemplateResponse("statistics.html", {"request": request, "statistics_url": statistics_url}) + +@app.api_route("/platform/statistics/", methods=["GET", "POST"]) +@app.api_route("/platform/statistics/{path:path}", methods=["GET", "POST"]) +async def statistics_proxy(request: Request, path: str = None, session: dict = Depends(get_session)): + """ + Proxy requests to the TensorBoard backend to fetch experiment statistics, + rewriting URLs and filtering headers as needed. + + Parameters: + request (Request): FastAPI request object with original headers, cookies, and body. + path (str, optional): Specific TensorBoard sub-path to proxy; defaults to None. + session (dict): Session data extracted via dependency. + + Returns: + Response: The proxied TensorBoard response with adjusted headers and content. + + Raises: + HTTPException: 401 Unauthorized if the user is not authenticated. + """ + if "user" in session: + query_string = urlencode(request.query_params) + + url = "http://localhost:8080" + tensorboard_url = f"{url}{('/' + path) if path else ''}" + ("?" + query_string if query_string else "") + + headers = {key: value for key, value in request.headers.items() if key.lower() != "host"} + + response = requests.request( + method=request.method, + url=tensorboard_url, + headers=headers, + data=await request.body(), + cookies=request.cookies, + allow_redirects=False, + ) + + excluded_headers = [ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + ] + + filtered_headers = [ + (name, value) for name, value in response.raw.headers.items() if name.lower() not in excluded_headers + ] + + if "text/html" in response.headers["Content-Type"]: + content = response.text + content = content.replace("url(/", "url(/platform/statistics/") + content = content.replace('src="/', 'src="/platform/statistics/') + content = content.replace('href="/', 'href="/platform/statistics/') + response = Response(content, response.status_code, dict(filtered_headers)) + return response + + if path and path.endswith(".js"): + content = response.text + content = content.replace( + "experiment/${s}/data/plugin", + "nebula/statistics/experiment/${s}/data/plugin", ) + response = Response(content, response.status_code, dict(filtered_headers)) + return response - excluded_headers = [ - "content-encoding", - "content-length", - "transfer-encoding", - "connection", - ] - - filtered_headers = [ - (name, value) for name, value in response.raw.headers.items() if name.lower() not in excluded_headers - ] - - if "text/html" in response.headers["Content-Type"]: - content = response.text - content = content.replace("url(/", "url(/platform/statistics/") - content = content.replace('src="/', 'src="/platform/statistics/') - content = content.replace('href="/', 'href="/platform/statistics/') - response = Response(content, response.status_code, dict(filtered_headers)) - return response - - if path and path.endswith(".js"): - content = response.text - content = content.replace( - "experiment/${s}/data/plugin", - "nebula/statistics/experiment/${s}/data/plugin", - ) - response = Response(content, response.status_code, dict(filtered_headers)) - return response + return Response(response.content, response.status_code, dict(filtered_headers)) - return Response(response.content, response.status_code, dict(filtered_headers)) + else: + raise HTTPException(status_code=401) - else: - raise HTTPException(status_code=401) +@app.get("/experiment/{path:path}") +@app.post("/experiment/{path:path}") +async def metrics_proxy(path: str = None, request: Request = None): + """ + Proxy experiment metric requests to the platform statistics endpoint. + + Parameters: + path (str): The dynamic path segment to append to the statistics URL. + request (Request): FastAPI request object containing query parameters. - @app.get("/experiment/{path:path}") - @app.post("/experiment/{path:path}") - async def metrics_proxy(path: str = None, request: Request = None): - query_params = request.query_params - new_url = "/platform/statistics/experiment/" + path - if query_params: - new_url += "?" + urlencode(query_params) + Returns: + RedirectResponse: Redirects the client to the corresponding platform statistics experiment URL. + """ + query_params = request.query_params + new_url = "/platform/statistics/experiment/" + path + if query_params: + new_url += "?" + urlencode(query_params) - return RedirectResponse(url=new_url) + return RedirectResponse(url=new_url) # TENSORBOARD END def zipdir(path, ziph): + """ + Recursively add all files from a directory into a zip archive. + + Parameters: + path (str): The root directory whose contents will be zipped. + ziph (zipfile.ZipFile): An open ZipFile handle to which files will be written. + + Returns: + None + """ # ziph is zipfile handle for root, _, files in os.walk(path): for file in files: @@ -1148,6 +1896,21 @@ def zipdir(path, ziph): async def nebula_dashboard_download_logs_metrics( scenario_name: str, request: Request, session: dict = Depends(get_session) ): + """ + Package scenario logs and configuration into a zip archive and stream it to the client. + + Parameters: + scenario_name (str): Name of the scenario whose files are to be downloaded. + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + StreamingResponse: A zip file containing the scenario’s logs and configuration. + + Raises: + HTTPException: 401 Unauthorized if the user is not logged in. + HTTPException: 404 Not Found if the log or config folder does not exist. + """ if "user" in session: log_folder = FileUtils.check_path(settings.log_dir, scenario_name) config_folder = FileUtils.check_path(settings.config_dir, scenario_name) @@ -1173,7 +1936,17 @@ async def nebula_dashboard_download_logs_metrics( @app.get("/platform/dashboard/deployment/", response_class=HTMLResponse) async def nebula_dashboard_deployment(request: Request, session: dict = Depends(get_session)): - scenario_running = get_running_scenario() + """ + Render the deployment dashboard with running scenarios and GPU availability. + + Parameters: + request (Request): FastAPI request object. + session (dict): Session data extracted via dependency. + + Returns: + HTMLResponse: Rendered 'deployment.html' template with scenario and GPU context. + """ + scenario_running = await get_running_scenarios() return templates.TemplateResponse( "deployment.html", { @@ -1185,115 +1958,17 @@ async def nebula_dashboard_deployment(request: Request, session: dict = Depends( ) -def attack_node_assign( - nodes, - federation, - attack, - poisoned_node_percent, - poisoned_sample_percent, - poisoned_noise_percent, -): - """Identify which nodes will be attacked""" - import math - import random - - attack_matrix = [] - n_nodes = len(nodes) - if n_nodes == 0: - return attack_matrix - - nodes_index = [] - # Get the nodes index - if federation == "DFL": - nodes_index = list(nodes.keys()) - else: - for node in nodes: - if nodes[node]["role"] != "server": - nodes_index.append(node) - - n_nodes = len(nodes_index) - # Number of attacked nodes, round up - num_attacked = int(math.ceil(poisoned_node_percent / 100 * n_nodes)) - if num_attacked > n_nodes: - num_attacked = n_nodes - - # Get the index of attacked nodes - attacked_nodes = random.sample(nodes_index, num_attacked) - - # Assign the role of each node - for node in nodes: - node_att = "No Attack" - attack_sample_persent = 0 - poisoned_ratio = 0 - if (node in attacked_nodes) or (nodes[node]["malicious"]): - node_att = attack - attack_sample_persent = poisoned_sample_percent / 100 - poisoned_ratio = poisoned_noise_percent / 100 - nodes[node]["attacks"] = node_att - nodes[node]["poisoned_sample_percent"] = attack_sample_persent - nodes[node]["poisoned_ratio"] = poisoned_ratio - attack_matrix.append([node, node_att, attack_sample_persent, poisoned_ratio]) - return nodes, attack_matrix - - -import math - - -def mobility_assign(nodes, mobile_participants_percent): - """Assign mobility to nodes""" - import random - - # Number of mobile nodes, round down - num_mobile = math.floor(mobile_participants_percent / 100 * len(nodes)) - if num_mobile > len(nodes): - num_mobile = len(nodes) - - # Get the index of mobile nodes - mobile_nodes = random.sample(list(nodes.keys()), num_mobile) - - # Assign the role of each node - for node in nodes: - node_mob = False - if node in mobile_nodes: - node_mob = True - nodes[node]["mobility"] = node_mob - return nodes - - -# Recieve a stopped node -@app.post("/platform/dashboard/{scenario_name}/node/done") -async def node_stopped(scenario_name: str, request: Request): - user = get_user_by_scenario_name(scenario_name) - user_data = user_data_store[user] - - if request.headers.get("content-type") == "application/json": - data = await request.json() - user_data.nodes_finished.append(data["idx"]) - nodes_list = list_nodes_by_scenario_name(scenario_name) - finished = True - # Check if all the nodes of the scenario have finished the experiment - for node in nodes_list: - if str(node[1]) not in map(str, user_data.nodes_finished): - finished = False - - if finished: - stop_scenario(scenario_name, user) - user_data.nodes_finished.clear() - user_data.finish_scenario_event.set() - return JSONResponse( - status_code=200, - content={"message": "All nodes finished, scenario marked as completed."}, - ) - else: - return JSONResponse( - status_code=200, - content={"message": "Node marked as finished, waiting for other nodes."}, - ) - else: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) +async def assign_available_gpu(scenario_data, role): + """ + Assign available GPU(s) or default to CPU for a scenario based on system resources and user role. + Parameters: + scenario_data (dict): Scenario configuration dict to be updated with accelerator settings. + role (str): User role ('user', 'admin', or other). -async def assign_available_gpu(scenario_data, role): + Returns: + dict: Updated scenario_data including 'accelerator' and 'gpu_id' fields. + """ available_gpus = [] response = await get_available_gpus() @@ -1301,7 +1976,7 @@ async def assign_available_gpu(scenario_data, role): available_system_gpus = response.get("available_gpus", None) if response is not None else None if available_system_gpus: - running_scenarios = get_running_scenario(get_all=True) + running_scenarios = get_running_scenarios(get_all=True) # Obtain currently used gpus if running_scenarios: running_gpus = [] @@ -1339,56 +2014,44 @@ async def assign_available_gpu(scenario_data, role): async def run_scenario(scenario_data, role, user): - import subprocess + """ + Deploy a single scenario: assign resources, register it, and start its participants. - from nebula.scenarios import ScenarioManagement + Parameters: + scenario_data (dict): The scenario configuration data. + role (str): The role of the user initiating the scenario. + user (str): Username associated with the scenario. + Returns: + None + """ user_data = user_data_store[user] scenario_data = await assign_available_gpu(scenario_data, role) - # Manager for the actual scenario - scenarioManagement = ScenarioManagement(scenario_data, user) - - scenario_update_record( - scenario_name=scenarioManagement.scenario_name, - username=user, - start_time=scenarioManagement.start_date_scenario, - end_time="", - status="running", - title=scenario_data["scenario_title"], - description=scenario_data["scenario_description"], - network_subnet=scenario_data["network_subnet"], - model=scenario_data["model"], - dataset=scenario_data["dataset"], - rounds=scenario_data["rounds"], - role=role, - gpu_id=json.dumps(scenario_data["gpu_id"]), - ) - - # Run the actual scenario - try: - if scenarioManagement.scenario.mobility: - additional_participants = scenario_data["additional_participants"] - schema_additional_participants = scenario_data["schema_additional_participants"] - scenarioManagement.load_configurations_and_start_nodes( - additional_participants, schema_additional_participants - ) - else: - scenarioManagement.load_configurations_and_start_nodes() - except subprocess.CalledProcessError as e: - logging.exception(f"Error docker-compose up: {e}") - return + + scenario_name = await deploy_scenario(scenario_data, role, user) - user_data.nodes_registration[scenarioManagement.scenario_name] = { + user_data.nodes_registration[scenario_name] = { "n_nodes": scenario_data["n_nodes"], "nodes": set(), } - user_data.nodes_registration[scenarioManagement.scenario_name]["condition"] = asyncio.Condition() + user_data.nodes_registration[scenario_name]["condition"] = asyncio.Condition() # Deploy the list of scenarios async def run_scenarios(role, user): + """ + Sequentially execute all enqueued scenarios for a user, waiting for each to complete + and for sufficient resources before starting the next. + + Parameters: + role (str): The role of the user initiating the scenarios. + user (str): Username associated with the scenarios. + + Returns: + None + """ try: user_data = user_data_store[user] @@ -1420,6 +2083,22 @@ async def nebula_dashboard_deployment_run( background_tasks: BackgroundTasks, session: dict = Depends(get_session), ): + """ + Handle incoming deployment requests to run one or more scenarios, enqueue them, + and trigger background execution. + + Parameters: + request (Request): FastAPI request object containing a JSON list of scenarios to run. + background_tasks (BackgroundTasks): Instance for scheduling tasks. + session (dict): Session data extracted via dependency. + + Returns: + RedirectResponse: Redirects to '/platform/dashboard' on successful enqueue. + + Raises: + HTTPException: 401 Unauthorized if the user is not logged in or content type is invalid. + HTTPException: 503 Service Unavailable if resources are insufficient. + """ enough_resources = await check_enough_resources() if "user" not in session: diff --git a/nebula/frontend/config/participant.json.example b/nebula/frontend/config/participant.json.example index 52e14b26f..48446432d 100755 --- a/nebula/frontend/config/participant.json.example +++ b/nebula/frontend/config/participant.json.example @@ -63,7 +63,6 @@ "mobility": false, "mobility_type": "topology", "topology_type": "", - "push_strategy": "slow", "radius_federation": 1000, "scheme_mobility": "random", "round_frequency": 1, diff --git a/nebula/frontend/static/css/dashboard.css b/nebula/frontend/static/css/dashboard.css index 709874056..2ad7b66dd 100644 --- a/nebula/frontend/static/css/dashboard.css +++ b/nebula/frontend/static/css/dashboard.css @@ -259,4 +259,4 @@ textarea.form-control:focus { @keyframes progress-shine { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } -} \ No newline at end of file +} diff --git a/nebula/frontend/static/css/particles.css b/nebula/frontend/static/css/particles.css index 9e9959948..b381a0ab4 100644 --- a/nebula/frontend/static/css/particles.css +++ b/nebula/frontend/static/css/particles.css @@ -15,4 +15,4 @@ canvas { background-position: 50% 50%; z-index: -1; pointer-events: auto; -} \ No newline at end of file +} diff --git a/nebula/frontend/static/css/style.css b/nebula/frontend/static/css/style.css index fdaf4c329..262732d28 100755 --- a/nebula/frontend/static/css/style.css +++ b/nebula/frontend/static/css/style.css @@ -630,7 +630,7 @@ hr.styled { -ms-overflow-style: -ms-autohiding-scrollbar; -webkit-overflow-scrolling: touch; } - + .container { max-width: 100%; padding-right: 10px; diff --git a/nebula/frontend/static/js/dashboard/config-manager.js b/nebula/frontend/static/js/dashboard/config-manager.js index 9913162cc..0550c8e05 100644 --- a/nebula/frontend/static/js/dashboard/config-manager.js +++ b/nebula/frontend/static/js/dashboard/config-manager.js @@ -36,4 +36,4 @@ const ConfigManager = { } }; -export default ConfigManager; \ No newline at end of file +export default ConfigManager; diff --git a/nebula/frontend/static/js/dashboard/dashboard.js b/nebula/frontend/static/js/dashboard/dashboard.js index a18421172..3926fa7d0 100644 --- a/nebula/frontend/static/js/dashboard/dashboard.js +++ b/nebula/frontend/static/js/dashboard/dashboard.js @@ -28,4 +28,4 @@ document.addEventListener('DOMContentLoaded', () => { Dashboard.init(); }); -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/nebula/frontend/static/js/dashboard/notes-manager.js b/nebula/frontend/static/js/dashboard/notes-manager.js index 6944e263c..aff9f57da 100644 --- a/nebula/frontend/static/js/dashboard/notes-manager.js +++ b/nebula/frontend/static/js/dashboard/notes-manager.js @@ -55,7 +55,7 @@ const NotesManager = { }); const data = await response.json(); - + if (data.status === 'success') { showAlert('success', 'Notes saved successfully'); } else { @@ -72,4 +72,4 @@ const NotesManager = { } }; -export default NotesManager; \ No newline at end of file +export default NotesManager; diff --git a/nebula/frontend/static/js/dashboard/scenario-actions.js b/nebula/frontend/static/js/dashboard/scenario-actions.js index d5cd915de..5933b1059 100644 --- a/nebula/frontend/static/js/dashboard/scenario-actions.js +++ b/nebula/frontend/static/js/dashboard/scenario-actions.js @@ -12,7 +12,7 @@ const ScenarioActions = { handleRelaunch(event) { const scenarioName = $(event.currentTarget).data('scenario-name'); const scenarioTitle = $(event.currentTarget).data('scenario-title'); - + $('#confirm-modal').modal('show'); $('#confirm-modal .modal-title').text('Relaunch scenario'); $('#confirm-modal #confirm-modal-body').html(`Are you sure you want to relaunch the scenario ${scenarioTitle}?`); @@ -24,7 +24,7 @@ const ScenarioActions = { handleRemove(event) { const scenarioName = $(event.currentTarget).data('scenario-name'); - + $('#confirm-modal').modal('show'); $('#confirm-modal .modal-title').text('Remove scenario'); $('#confirm-modal #confirm-modal-body').html( @@ -78,4 +78,4 @@ const ScenarioActions = { } }; -export default ScenarioActions; \ No newline at end of file +export default ScenarioActions; diff --git a/nebula/frontend/static/js/deployment/attack.js b/nebula/frontend/static/js/deployment/attack.js index 8ede4e7e2..61f1a4c62 100644 --- a/nebula/frontend/static/js/deployment/attack.js +++ b/nebula/frontend/static/js/deployment/attack.js @@ -229,4 +229,4 @@ const AttackManager = (function() { }; })(); -export default AttackManager; \ No newline at end of file +export default AttackManager; diff --git a/nebula/frontend/static/js/deployment/graph-settings.js b/nebula/frontend/static/js/deployment/graph-settings.js index 0c3d93be6..026a3632e 100644 --- a/nebula/frontend/static/js/deployment/graph-settings.js +++ b/nebula/frontend/static/js/deployment/graph-settings.js @@ -38,4 +38,4 @@ const GraphSettings = (function() { }; })(); -export default GraphSettings; \ No newline at end of file +export default GraphSettings; diff --git a/nebula/frontend/static/js/deployment/help-content.js b/nebula/frontend/static/js/deployment/help-content.js index 56e80d916..673cae881 100644 --- a/nebula/frontend/static/js/deployment/help-content.js +++ b/nebula/frontend/static/js/deployment/help-content.js @@ -175,4 +175,4 @@ const HelpContent = (function() { }; })(); -export default HelpContent; \ No newline at end of file +export default HelpContent; diff --git a/nebula/frontend/static/js/deployment/main.js b/nebula/frontend/static/js/deployment/main.js index 13e298582..51d482294 100644 --- a/nebula/frontend/static/js/deployment/main.js +++ b/nebula/frontend/static/js/deployment/main.js @@ -4,6 +4,7 @@ import TopologyManager from './topology.js'; import AttackManager from './attack.js'; import MobilityManager from './mobility.js'; import ReputationManager from './reputation.js'; +import SaManager from './situational-awareness.js'; import GraphSettings from './graph-settings.js'; import Utils from './utils.js'; @@ -11,12 +12,12 @@ const DeploymentManager = (function() { function initialize() { // First initialize all modules initializeModules(); - + // Then initialize event listeners and UI controls initializeEventListeners(); setupDeploymentButtons(); initializeSelectElements(); - + // Finally initialize scenarios after all modules are ready ScenarioManager.initializeScenarios(); } @@ -27,14 +28,16 @@ const DeploymentManager = (function() { AttackManager.initializeEventListeners(); MobilityManager.initializeMobility(); ReputationManager.initializeReputationSystem(); + SaManager.initializeSa(); GraphSettings.initializeDistanceControls(); - + // Make modules globally available window.ScenarioManager = ScenarioManager; window.TopologyManager = TopologyManager; window.AttackManager = AttackManager; window.MobilityManager = MobilityManager; window.ReputationManager = ReputationManager; + window.SaManager = SaManager; window.GraphSettings = GraphSettings; window.DeploymentManager = DeploymentManager; window.Utils = Utils; @@ -52,7 +55,7 @@ const DeploymentManager = (function() { window.addEventListener("resize", handleResize); window.addEventListener("click", handleOutsideClick); setupDatasetListeners(); - setupInputValidation(); + //setupInputValidation(); } function handleResize() { @@ -257,8 +260,8 @@ const DeploymentManager = (function() { input.addEventListener('input', () => Utils.greaterThan0(input)); } if(input.hasAttribute('min') && input.hasAttribute('max')) { - input.addEventListener('input', () => Utils.isInRange(input, - parseInt(input.getAttribute('min')), + input.addEventListener('input', () => Utils.isInRange(input, + parseInt(input.getAttribute('min')), parseInt(input.getAttribute('max')))); } }); @@ -270,4 +273,4 @@ const DeploymentManager = (function() { }; })(); -export default DeploymentManager; \ No newline at end of file +export default DeploymentManager; diff --git a/nebula/frontend/static/js/deployment/mobility.js b/nebula/frontend/static/js/deployment/mobility.js index 924cdb9bb..393dd116f 100644 --- a/nebula/frontend/static/js/deployment/mobility.js +++ b/nebula/frontend/static/js/deployment/mobility.js @@ -10,7 +10,7 @@ const MobilityManager = { setupLocationControls() { const customLocationDiv = document.getElementById("mobility-custom-location"); - + document.getElementById("random-geo-btn").addEventListener("click", () => { customLocationDiv.style.display = "none"; }); @@ -42,7 +42,7 @@ const MobilityManager = { setupMobilityControls() { const mobilityOptionsDiv = document.getElementById("mobility-options"); - + document.getElementById("without-mobility-btn").addEventListener("click", () => { mobilityOptionsDiv.style.display = "none"; if (this.map) { @@ -67,10 +67,10 @@ const MobilityManager = { initializeMap() { if (!this.map) { this.map = L.map('map').setView([38.023522, -1.174389], 17); - + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© enriquetomasmb.com', - maxZoom: 18, + maxZoom: 15, }).addTo(this.map); this.addInitialMarker(); @@ -134,14 +134,33 @@ const MobilityManager = { setupAdditionalParticipants() { document.getElementById("additionalParticipants").addEventListener("change", function() { - const container = document.getElementById("additional-participants-items"); - container.innerHTML = ""; + if(this.value > 0) { + document.getElementById("connectionDelaytitle").style.display = "block"; + document.getElementById("connectionDelayDiv").style.display = "block"; + } else { + document.getElementById("connectionDelaytitle").style.display = "none"; + document.getElementById("connectionDelayDiv").style.display = "none"; + } + }); - for (let i = 0; i < this.value; i++) { - const participantItem = this.createParticipantItem(i); - container.appendChild(participantItem); + document.getElementById("connectionDelaySwitch").addEventListener("change", function() { + if(this.checked) { + document.getElementById("connectionDelay").style.display = "inline"; + $(".additional-participant-item").remove(); + } else { + document.getElementById("connectionDelay").style.display = "none"; + + //Generate additional participants + const container = document.getElementById("additional-participants-items"); + container.innerHTML = ""; + + let additionalParticipants = document.getElementById("additionalParticipants"); + for (let i = 0; i < additionalParticipants.value; i++) { + const participantItem = MobilityManager.createParticipantItem(i); + container.appendChild(participantItem); + } } - }.bind(this)); + }); }, createParticipantItem(index) { @@ -151,7 +170,8 @@ const MobilityManager = { const heading = document.createElement("h5"); heading.textContent = `Round of deployment (participant ${index + 1})`; - + heading.classList.add("step-title") + const input = document.createElement("input"); input.type = "number"; input.classList.add("form-control"); @@ -176,6 +196,7 @@ const MobilityManager = { latitude: parseFloat(document.getElementById("latitude").value), longitude: parseFloat(document.getElementById("longitude").value) }, + network_simulation: document.getElementById("networkSimulation").checked, mobilityType: document.getElementById("mobilitySelect").value, radiusFederation: parseInt(document.getElementById("radiusFederation").value), schemeMobility: document.getElementById("schemeMobilitySelect").value, @@ -222,7 +243,7 @@ const MobilityManager = { document.getElementById("random-geo-btn").checked = config.randomGeo; document.getElementById("custom-location-btn").checked = !config.randomGeo; document.getElementById("mobility-custom-location").style.display = config.randomGeo ? "none" : "block"; - + if (config.location) { document.getElementById("latitude").value = config.location.latitude; document.getElementById("longitude").value = config.location.longitude; @@ -232,8 +253,9 @@ const MobilityManager = { } // Set mobility settings + document.getElementById("networkSimulation").checked = config.network_simulation, document.getElementById("mobilitySelect").value = config.mobilityType || "both"; - document.getElementById("radiusFederation").value = config.radiusFederation || 100; + document.getElementById("radiusFederation").value = config.radiusFederation || 500; document.getElementById("schemeMobilitySelect").value = config.schemeMobility || "random"; document.getElementById("roundFrequency").value = config.roundFrequency || 1; document.getElementById("mobileParticipantsPercent").value = config.mobileParticipantsPercent || 100; @@ -271,7 +293,7 @@ const MobilityManager = { document.getElementById("latitude").value = "38.023522"; document.getElementById("longitude").value = "-1.174389"; document.getElementById("mobilitySelect").value = "both"; - document.getElementById("radiusFederation").value = "100"; + document.getElementById("radiusFederation").value = "500"; document.getElementById("schemeMobilitySelect").value = "random"; document.getElementById("roundFrequency").value = "1"; document.getElementById("mobileParticipantsPercent").value = "100"; @@ -285,4 +307,4 @@ const MobilityManager = { } }; -export default MobilityManager; \ No newline at end of file +export default MobilityManager; diff --git a/nebula/frontend/static/js/deployment/reputation.js b/nebula/frontend/static/js/deployment/reputation.js index f65e1595f..b0e341e4a 100644 --- a/nebula/frontend/static/js/deployment/reputation.js +++ b/nebula/frontend/static/js/deployment/reputation.js @@ -161,4 +161,4 @@ const ReputationManager = (function() { }; })(); -export default ReputationManager; \ No newline at end of file +export default ReputationManager; diff --git a/nebula/frontend/static/js/deployment/scenario.js b/nebula/frontend/static/js/deployment/scenario.js index 39622efe6..bf0f41c61 100644 --- a/nebula/frontend/static/js/deployment/scenario.js +++ b/nebula/frontend/static/js/deployment/scenario.js @@ -7,23 +7,23 @@ const ScenarioManager = (function() { function initializeScenarios() { // Clear session storage sessionStorage.removeItem("ScenarioList"); - + // Reset the scenarios list scenariosList = []; actual_scenario = 0; - + // Clear all fields and reset modules clearFields(); - + // Update UI updateScenariosPosition(true); } function collectScenarioData() { + window.TopologyManager.updateGraph(); const topologyData = window.TopologyManager.getData(); const nodes = {}; const nodes_graph = {}; - // Convert nodes array to objects with string IDs topologyData.nodes.forEach(node => { const nodeId = node.id.toString(); @@ -109,6 +109,7 @@ const ScenarioManager = (function() { weight_num_messages: window.ReputationManager.getReputationConfig().weight_num_messages || 0.25, weight_fraction_params_changed: window.ReputationManager.getReputationConfig().weight_fraction_params_changed || 0.25, mobility: window.MobilityManager.getMobilityConfig().enabled || false, + network_simulation: window.MobilityManager.getMobilityConfig().network_simulation || false, mobility_type: window.MobilityManager.getMobilityConfig().mobilityType || "random", radius_federation: window.MobilityManager.getMobilityConfig().radiusFederation || 1000, scheme_mobility: window.MobilityManager.getMobilityConfig().schemeMobility || "random", @@ -117,6 +118,18 @@ const ScenarioManager = (function() { random_geo: window.MobilityManager.getMobilityConfig().randomGeo || false, latitude: window.MobilityManager.getMobilityConfig().location.latitude || 0, longitude: window.MobilityManager.getMobilityConfig().location.longitude || 0, + //with_sa : window.SaManager.getSaConfig().with_sa || false, + with_sa: window.MobilityManager.getMobilityConfig().enabled || false, + //strict_topology: window.SaManager.getSaConfig().strict_topology || false, + strict_topology: false, + //sad_candidate_selector: window.SaManager.getSaConfig().sad_candidate_selector.value || "Distance", + sad_candidate_selector: "Distance", + //sad_model_handler: window.SaManager.getSaConfig().sad_model_handler.value || "std", + sad_model_handler: "std", + // sar_arbitration_policy: window.SaManager.getSaConfig().sar_arbitration_policy.value || "sap", + sar_arbitration_policy: "sap", + //sar_neighbor_policy: window.SaManager.getSaConfig().sar_neighbor_policy.value || "Distance", + sar_neighbor_policy: "Distance", random_topology_probability: document.getElementById("random-probability").value || 0.5, network_subnet: "172.20.0.0/16", network_gateway: "172.20.0.1", @@ -133,7 +146,7 @@ const ScenarioManager = (function() { // Load basic fields document.getElementById("scenario-title").value = scenario.scenario_title || ""; document.getElementById("scenario-description").value = scenario.scenario_description || ""; - + // Load deployment const deploymentRadio = document.querySelector(`input[name="deploymentRadioOptions"][value="${scenario.deployment}"]`); if (deploymentRadio) deploymentRadio.checked = true; @@ -148,7 +161,7 @@ const ScenarioManager = (function() { nodes: Object.values(scenario.nodes), links: [] }; - + // Reconstruct links from the nodes' neighbors topologyData.nodes.forEach(node => { if (node.neighbors) { @@ -160,7 +173,7 @@ const ScenarioManager = (function() { }); } }); - + window.TopologyManager.setData(topologyData); } else { window.TopologyManager.generatePredefinedTopology(); @@ -194,6 +207,7 @@ const ScenarioManager = (function() { if (scenario.mobility) { window.MobilityManager.setMobilityConfig({ enabled: scenario.mobility, + network_simulation: scenario.network_simulation, mobilityType: scenario.mobility_type, radiusFederation: scenario.radius_federation, schemeMobility: scenario.scheme_mobility, @@ -219,6 +233,16 @@ const ScenarioManager = (function() { weight_fraction_params_changed: scenario.weight_fraction_params_changed }); } + if (scenario.with_sa) { + window.SaManager.setSaConfig({ + with_sa: scenario.with_sa, + strict_topology: scenario.strict_topology, + sad_candidate_selector: scenario.sad_candidate_selector, + sad_model_handler: scenario.sad_model_handler, + sar_arbitration_policy: scenario.sar_arbitration_policy, + sar_neighbor_policy: scenario.sar_neighbor_policy + }); + } // Trigger necessary events document.getElementById("federationArchitecture").dispatchEvent(new Event('change')); @@ -236,25 +260,25 @@ const ScenarioManager = (function() { function deleteScenario() { if (scenariosList.length === 0) return; - + scenariosList.splice(actual_scenario, 1); if (actual_scenario >= scenariosList.length) { actual_scenario = Math.max(0, scenariosList.length - 1); } - + if (scenariosList.length > 0) { loadScenarioData(scenariosList[actual_scenario]); } else { clearFields(); } - + sessionStorage.setItem("ScenarioList", JSON.stringify(scenariosList)); updateScenariosPosition(scenariosList.length === 0); } function replaceScenario() { if (actual_scenario < 0 || actual_scenario >= scenariosList.length) return; - + const scenarioData = collectScenarioData(); scenariosList[actual_scenario] = scenarioData; sessionStorage.setItem("ScenarioList", JSON.stringify(scenariosList)); @@ -263,10 +287,10 @@ const ScenarioManager = (function() { function updateScenariosPosition(isEmptyScenario = false) { const container = document.getElementById("scenarios-position"); if (!container) return; - + // Clear existing content container.innerHTML = ''; - + if (isEmptyScenario) { container.innerHTML = 'No scenarios'; return; @@ -275,12 +299,12 @@ const ScenarioManager = (function() { // Create a single span for all scenarios const span = document.createElement("span"); span.style.margin = "0 10px"; - + // Create the scenario indicators - const indicators = scenariosList.map((_, index) => + const indicators = scenariosList.map((_, index) => index === actual_scenario ? `●` : `β—‹` ).join(' '); - + span.textContent = indicators; container.appendChild(span); } @@ -317,6 +341,9 @@ const ScenarioManager = (function() { if (window.ReputationManager) { window.ReputationManager.resetReputationConfig(); } + if (window.SaManager) { + window.SaManager.resetSaConfig(); + } // Trigger necessary events document.getElementById("federationArchitecture").dispatchEvent(new Event('change')); @@ -334,7 +361,7 @@ const ScenarioManager = (function() { initializeScenarios, getScenariosList: () => scenariosList, getActualScenario: () => actual_scenario, - setActualScenario: (index) => { + setActualScenario: (index) => { actual_scenario = index; if (scenariosList[index]) { loadScenarioData(scenariosList[index]); @@ -350,4 +377,4 @@ const ScenarioManager = (function() { }; })(); -export default ScenarioManager; \ No newline at end of file +export default ScenarioManager; diff --git a/nebula/frontend/static/js/deployment/situational-awareness.js b/nebula/frontend/static/js/deployment/situational-awareness.js new file mode 100644 index 000000000..969ae94c3 --- /dev/null +++ b/nebula/frontend/static/js/deployment/situational-awareness.js @@ -0,0 +1,75 @@ +const SaManager = (function() { + function initializeSa() { + setupSaSwitch(); + StrictTopologySwitch(); + } + + function setupSaSwitch() { + document.getElementById("situationalAwarenessSwitch").addEventListener("change", function() { + const sa_settings = document.getElementById("sa-settings"); + const sa_discovery_settings = document.getElementById("sa-discovery-settings"); + const sa_reasoner_settings = document.getElementById("sa-reasoner-settings"); + + sa_settings.style.display = this.checked ? "block" : "none"; + sa_discovery_settings.style.display = this.checked ? "block" : "none"; + sa_reasoner_settings.style.display = this.checked ? "block" : "none"; + }); + } + + function StrictTopologySwitch(){ + document.getElementById("StrictTopologySwitch").addEventListener("change", function() { + const candidate_selector = document.getElementById("candidate-selector-select"); + const neighbor_policy = document.getElementById("neighbor-policy-select"); + + if (this.checked) { + candidate_selector.value = document.getElementById("predefined-topology-select").value; + neighbor_policy.value = document.getElementById("predefined-topology-select").value; + candidate_selector.disabled = true; + neighbor_policy.disabled = true; + } else { + candidate_selector.value = "Distance"; + neighbor_policy.value = "Distance"; + candidate_selector.disabled = false; + neighbor_policy.disabled = false; + } + }); + } + + function getSaConfig() { + return { + with_sa: document.getElementById("situationalAwarenessSwitch").checked, + strict_topology: document.getElementById("StrictTopologySwitch").checked, + sad_candidate_selector: document.getElementById("candidate-selector-select").value, + sad_model_handler: document.getElementById("model-handler-select").value, + sar_arbitration_policy: document.getElementById("arbitration-policy-select").value, + sar_neighbor_policy: document.getElementById("neighbor-policy-select").value, + }; + } + + function setSaConfig(config) { + document.getElementById("situationalAwarenessSwitch").checked = config.with_sa; + document.getElementById("StrictTopologySwitch").checked = config.strict_topology; + document.getElementById("candidate-selector-select").value = config.sad_candidate_selector; + document.getElementById("model-handler-select").value = config.sad_model_handler; + document.getElementById("arbitration-policy-select").value = config.sar_arbitration_policy; + document.getElementById("neighbor-policy-select").value = config.sar_neighbor_policy; + } + + function resetSaConfig() { + document.getElementById("situationalAwarenessSwitch").checked = false; + document.getElementById("StrictTopologySwitch").checked = false; + document.getElementById("candidate-selector-select").value = "Distance"; + document.getElementById("model-handler-select").value = "std"; + document.getElementById("arbitration-policy-select").value = "sap"; + document.getElementById("neighbor-policy-select").value = "Distance"; + } + + return { + initializeSa, + getSaConfig, + setSaConfig, + resetSaConfig + }; +})(); + +export default SaManager; diff --git a/nebula/frontend/static/js/deployment/topology.js b/nebula/frontend/static/js/deployment/topology.js index 62c198b0e..18d4f287c 100644 --- a/nebula/frontend/static/js/deployment/topology.js +++ b/nebula/frontend/static/js/deployment/topology.js @@ -51,7 +51,7 @@ const TopologyManager = (function() { document.getElementById('federationArchitecture').addEventListener('change', function() { const federationType = this.value; const topologySelect = document.getElementById('predefined-topology-select'); - + if (federationType === 'CFL') { // For CFL, only allow Star topology topologySelect.value = 'Star'; @@ -64,7 +64,7 @@ const TopologyManager = (function() { topologySelect.disabled = false; customTopologyBtn.disabled = false; } - + generatePredefinedTopology(); }); @@ -92,7 +92,7 @@ const TopologyManager = (function() { const topologyType = document.getElementById('predefined-topology-select').value; const N = parseInt(document.getElementById('predefined-topology-nodes').value) || 3; let probability = 0.5; // default value - + if (topologyType === 'Random') { const probSelect = document.getElementById('random-probability'); probability = parseFloat(probSelect.value); @@ -319,24 +319,24 @@ const TopologyManager = (function() { // Find and remove both directional links const source = typeof link.source === 'object' ? link.source.id : link.source; const target = typeof link.target === 'object' ? link.target.id : link.target; - + gData.links = gData.links.filter(l => { const lSource = typeof l.source === 'object' ? l.source.id : l.source; const lTarget = typeof l.target === 'object' ? l.target.id : l.target; return !((lSource === source && lTarget === target) || (lSource === target && lTarget === source)); }); - + // Remove from neighbors gData.nodes[source].neighbors = gData.nodes[source].neighbors.filter(id => id !== target); gData.nodes[target].neighbors = gData.nodes[target].neighbors.filter(id => id !== source); - + updateGraph(); } function addNode(sourceNode) { document.getElementById("custom-topology-btn").checked = true; document.getElementById("predefined-topology").style.display = "none"; - + const newNode = { id: gData.nodes.length, ip: "127.0.0.1", @@ -348,7 +348,7 @@ const TopologyManager = (function() { neighbors: [sourceNode.id], links: [] }; - + sourceNode.neighbors.push(newNode.id); gData.nodes.push(newNode); gData.links.push({ source: newNode.id, target: sourceNode.id }); @@ -362,7 +362,7 @@ const TopologyManager = (function() { document.getElementById("predefined-topology").style.display = "none"; // Remove links connected to this node - gData.links = gData.links.filter(l => + gData.links = gData.links.filter(l => l.source.id !== node.id && l.target.id !== node.id ); @@ -471,7 +471,7 @@ const TopologyManager = (function() { const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(10, 10 * 0.7, 5); sprite.position.set(0, 5, 0); - + return sprite; } @@ -533,7 +533,7 @@ const TopologyManager = (function() { function updateIPsAndPorts() { const isProcess = document.getElementById("process-radio").checked; const baseIP = "192.168.50"; - + gData.nodes.forEach((node, index) => { node.ip = isProcess ? "127.0.0.1" : `${baseIP}.${index + 2}`; node.port = (45001 + index).toString(); @@ -542,7 +542,7 @@ const TopologyManager = (function() { function getMatrix() { const matrix = Array(gData.nodes.length).fill().map(() => Array(gData.nodes.length).fill(0)); - + gData.links.forEach(link => { const source = typeof link.source === 'object' ? link.source.id : link.source; const target = typeof link.target === 'object' ? link.target.id : link.target; @@ -573,7 +573,7 @@ const TopologyManager = (function() { function assignRolesByFederationArchitecture() { const federationType = document.getElementById("federationArchitecture").value; const nodes = gData.nodes; - + if (nodes.length === 0) return; switch (federationType) { @@ -584,7 +584,7 @@ const TopologyManager = (function() { nodes[i].role = "trainer"; } break; - + case "SDFL": // All as trainers except one random node as aggregator const randomIndex = Math.floor(Math.random() * nodes.length); @@ -592,7 +592,7 @@ const TopologyManager = (function() { nodes[i].role = i === randomIndex ? "aggregator" : "trainer"; } break; - + case "DFL": // All as aggregators for (let i = 0; i < nodes.length; i++) { @@ -600,7 +600,7 @@ const TopologyManager = (function() { } break; } - + // Force complete graph update if (Graph) { Graph.nodeThreeObject(node => createNodeObject(node)); @@ -626,7 +626,7 @@ const TopologyManager = (function() { generatePredefinedTopology(); return; } - + // Ensure each node has the required properties data.nodes = data.nodes.map(node => ({ id: node.id, @@ -636,13 +636,13 @@ const TopologyManager = (function() { neighbors: node.neighbors || [], links: node.links || [] })); - + // Ensure each link has the required properties data.links = data.links.map(link => ({ source: link.source, target: link.target })); - + gData = data; updateGraph(); }, @@ -652,4 +652,4 @@ const TopologyManager = (function() { }; })(); -export default TopologyManager; \ No newline at end of file +export default TopologyManager; diff --git a/nebula/frontend/static/js/deployment/ui-controls.js b/nebula/frontend/static/js/deployment/ui-controls.js index c85171b2c..dcf321e2e 100644 --- a/nebula/frontend/static/js/deployment/ui-controls.js +++ b/nebula/frontend/static/js/deployment/ui-controls.js @@ -22,7 +22,7 @@ const UIControls = (function() { if (modeBtn) { modeBtn.addEventListener('click', function() { const isAdvancedMode = modeBtn.innerHTML.trim() === "Advanced mode"; - + if (isAdvancedMode) { // Switch to advanced mode modeBtn.innerHTML = "User mode"; @@ -300,11 +300,11 @@ const UIControls = (function() { yesButton.disabled = false; const modal = new bootstrap.Modal(confirmModal); - + confirmModal.addEventListener('hidden.bs.modal', function () { cleanupModal(confirmModal); }); - + modal.show(); yesButton.onclick = async () => { @@ -355,7 +355,7 @@ const UIControls = (function() { function handleDeploymentError(status, error = null) { hideLoadingIndicators(); let errorMessage; - + switch(status) { case 401: errorMessage = "You are not authorized to run a scenario. Please log in."; @@ -377,12 +377,12 @@ const UIControls = (function() { const infoModalBody = document.getElementById('info-modal-body'); infoModalBody.innerHTML = message; const modal = new bootstrap.Modal(infoModal); - + // Add event listener for when modal is hidden infoModal.addEventListener('hidden.bs.modal', function () { cleanupModal(infoModal); }); - + modal.show(); } @@ -423,7 +423,7 @@ const UIControls = (function() { const nodes = graph.graphData().nodes; const numberOfNodes = nodes.length; - + // Update the info-participants number const infoParticipantsNumber = document.getElementById("info-participants-number"); if (infoParticipantsNumber) { @@ -532,7 +532,7 @@ const UIControls = (function() { function showParticipantDetails(node, index) { const modalTitle = document.getElementById("participant-modal-title"); const modalContent = document.getElementById("participant-modal-content"); - + modalTitle.innerHTML = `Participant ${index}`; modalContent.innerHTML = ""; @@ -639,4 +639,4 @@ const UIControls = (function() { }; })(); -export default UIControls; \ No newline at end of file +export default UIControls; diff --git a/nebula/frontend/static/js/deployment/utils.js b/nebula/frontend/static/js/deployment/utils.js index 27f16059c..eb697adce 100644 --- a/nebula/frontend/static/js/deployment/utils.js +++ b/nebula/frontend/static/js/deployment/utils.js @@ -24,7 +24,7 @@ const Utils = (function() { value = Math.min(Math.max(value, 0), 1); } input.value = value.toFixed(1); - + // Trigger topology update if Random is selected const topologySelect = document.getElementById('predefined-topology-select'); if (topologySelect && topologySelect.value === 'Random') { @@ -55,4 +55,4 @@ const Utils = (function() { }; })(); -export default Utils; \ No newline at end of file +export default Utils; diff --git a/nebula/frontend/static/js/monitor/monitor.js b/nebula/frontend/static/js/monitor/monitor.js index db9a2f259..a04c9251c 100644 --- a/nebula/frontend/static/js/monitor/monitor.js +++ b/nebula/frontend/static/js/monitor/monitor.js @@ -1,6 +1,9 @@ // Monitor page functionality class Monitor { constructor() { + // Debug flag to control logging + this.debug = false; + // Get scenario name from URL path const pathParts = window.location.pathname.split('/'); this.scenarioName = pathParts[pathParts.indexOf('dashboard') + 1]; @@ -14,20 +17,51 @@ class Monitor { }; this.nodeTimestamps = new Map(); // Track last update time for each node this.nodePositions = new Map(); // Track node positions - + this.isInitialDataLoaded = false; // Flag to track initial data loading + this.processingUpdates = false; // Flag to prevent concurrent updates + this.pendingGraphUpdate = false; // Flag to track pending graph updates + this.updateTimeout = null; // Timeout for debounced graph updates + this.initializeMap(); this.initializeGraph(); this.initializeWebSocket(); this.initializeEventListeners(); this.initializeDownloadHandlers(); this.loadInitialData(); - + this.startStaleNodeCheck(); this.startPeriodicStatusCheck(); } + // Helper method for logging + log(...args) { + if (this.debug) { + console.log(...args); + } + } + + // Helper method for warning logs + warn(...args) { + if (this.debug) { + console.warn(...args); + } + } + + // Helper method for error logs + error(...args) { + // Always log errors regardless of debug flag + console.error(...args); + } + + // Helper method for info logs + info(...args) { + if (this.debug) { + console.info(...args); + } + } + initializeMap() { - console.log('Initializing map...'); + this.log('Initializing map...'); this.map = L.map('map', { center: [38.023522, -1.174389], zoom: 17, @@ -39,18 +73,18 @@ class Monitor { worldCopyJump: false, }); - console.log('Adding tile layer...'); + this.log('Adding tile layer...'); L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© enriquetomasmb.com' }).addTo(this.map); // Initialize line layer - console.log('Initializing line layer...'); + this.log('Initializing line layer...'); this.lineLayer = L.layerGroup().addTo(this.map); - console.log('Line layer added to map:', this.lineLayer); - + this.log('Line layer added to map:', this.lineLayer); + // Initialize drone icons - console.log('Initializing drone icons...'); + this.log('Initializing drone icons...'); this.droneIcon = L.icon({ iconUrl: '/platform/static/images/drone.svg', iconSize: [28, 28], @@ -74,12 +108,13 @@ class Monitor { `; document.head.appendChild(style); - console.log('Map initialization complete'); + this.log('Map initialization complete'); } initializeGraph() { const width = document.getElementById('3d-graph').offsetWidth; - + + // Initialize with basic configuration first this.Graph = ForceGraph3D()(document.getElementById('3d-graph')) .width(width) .height(600) @@ -92,21 +127,21 @@ class Monitor { .linkColor(link => { const sourceNode = this.gData.nodes.find(n => n.ipport === link.source); const targetNode = this.gData.nodes.find(n => n.ipport === link.target); - return (sourceNode && this.offlineNodes.has(sourceNode.ipport)) || - (targetNode && this.offlineNodes.has(targetNode.ipport)) ? '#ff0000' : '#999'; + return (sourceNode && this.offlineNodes.has(sourceNode.ip)) || + (targetNode && this.offlineNodes.has(targetNode.ip)) ? '#ff0000' : '#999'; }) .linkOpacity(0.6) .linkWidth(2) .linkDirectionalParticles(2) .linkDirectionalParticleSpeed(0.005) - .linkDirectionalParticleWidth(2) - .d3AlphaDecay(0) // Disable automatic decay - .d3VelocityDecay(0) // Disable velocity decay - .warmupTicks(0) // Disable warmup - .cooldownTicks(0) // Disable cooldown - .d3Force('center', null) // Disable center force - .d3Force('charge', null) // Disable charge force - .d3Force('link', d3.forceLink().id(d => d.ipport).distance(50).strength(1)); // Enable link force for visualization + .linkDirectionalParticleWidth(2); + + // Configure forces after basic initialization + this.Graph + .d3AlphaDecay(0.02) + .d3VelocityDecay(0.1) + .warmupTicks(50) + .cooldownTicks(50); // Set initial camera position this.Graph.cameraPosition({ x: 0, y: 0, z: 300 }, { x: 0, y: 0, z: 0 }, 0); @@ -126,36 +161,50 @@ class Monitor { layoutNodes(nodes) { const radius = 50; const center = { x: 0, y: 0, z: 0 }; - - console.log('Layouting nodes:', nodes); - - nodes.forEach((node, i) => { + + this.log('Layouting nodes:', nodes); + + return nodes.map((node, i) => { // Calculate angle based on node index const angle = (2 * Math.PI * i) / nodes.length; - + // Position nodes in a circle on the x-y plane - node.x = center.x + radius * Math.cos(angle); - node.y = center.y + radius * Math.sin(angle); - node.z = center.z; // Keep all nodes at the same z-level initially - - // Store the fixed position - node.fx = node.x; - node.fy = node.y; - node.fz = node.z; - - console.log(`Node ${node.ipport} positioned at:`, { x: node.x, y: node.y, z: node.z }); + const x = center.x + radius * Math.cos(angle); + const y = center.y + radius * Math.sin(angle); + const z = center.z; // Keep all nodes at the same z-level initially + + return { + ...node, + x, + y, + z, + fx: x, + fy: y, + fz: z + }; }); - - return nodes; } loadInitialData() { if (!this.scenarioName) { - console.error('No scenario name found in URL'); + this.error('No scenario name found in URL'); return; } - console.log('Loading initial data for scenario:', this.scenarioName); + // Clear existing data + this.gData.nodes = []; + this.gData.links = []; + this.droneMarkers = {}; + this.droneLines = {}; + this.offlineNodes.clear(); + this.nodeTimestamps.clear(); + this.isInitialDataLoaded = false; + this.pendingGraphUpdate = false; + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + } + + this.log('Loading initial data for scenario:', this.scenarioName); fetch(`/platform/api/dashboard/${this.scenarioName}/monitor`) .then(response => { if (!response.ok) { @@ -164,9 +213,21 @@ class Monitor { return response.json(); }) .then(data => { - console.log('Received initial data:', data); + this.log('Received initial data:', data); if (data.nodes && data.nodes.length > 0) { + // Create a Set to track unique nodes + const uniqueNodes = new Set(); + data.nodes.forEach(node => { + const nodeId = `${node.ip}:${node.port}`; + + // Skip if we've already processed this node + if (uniqueNodes.has(nodeId)) { + this.log('Skipping duplicate node:', nodeId); + return; + } + uniqueNodes.add(nodeId); + const nodeData = { uid: node.uid, idx: node.idx, @@ -188,7 +249,7 @@ class Monitor { // Update offline nodes set if (!nodeData.status) { this.offlineNodes.add(nodeData.ip); - console.log(`Node ${nodeData.ip}:${nodeData.port} marked as offline from initial data`); + this.log(`Node ${nodeData.ip}:${nodeData.port} marked as offline from initial data`); } // Update table @@ -198,7 +259,6 @@ class Monitor { this.updateQueue.push(nodeData); // Add to graph data - const nodeId = `${nodeData.ip}:${nodeData.port}`; this.gData.nodes.push({ id: nodeData.idx, ip: nodeData.ip, @@ -210,48 +270,24 @@ class Monitor { }); }); - // Create links between online nodes - data.nodes.forEach(sourceNode => { - const sourceData = { - ip: sourceNode.ip, - port: sourceNode.port, - status: sourceNode.status, - neighbors: sourceNode.neighbors - }; - - if (sourceData.status && sourceData.neighbors) { - const neighbors = sourceData.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== ''); - neighbors.forEach(neighbor => { - const [neighborIP, neighborPort] = neighbor.split(':'); - const targetNode = data.nodes.find(n => n.ip === neighborIP && n.port === neighborPort); - if (targetNode && targetNode.status) { - this.gData.links.push({ - source: `${sourceData.ip}:${sourceData.port}`, - target: `${neighborIP}:${neighborPort}`, - value: this.randomFloatFromInterval(1.0, 1.3) - }); - } - }); - } - }); - // Process queue and update visualizations this.processQueue(); this.updateGraph(); } else { - console.warn('No nodes in initial data'); + this.log('No nodes in initial data'); } + this.isInitialDataLoaded = true; }) .catch(error => { - console.error('Error loading initial data:', error); + this.error('Error loading initial data:', error); showAlert('danger', 'Error loading initial data. Please refresh the page.'); }); } processInitialData(data) { - console.log('Processing initial data:', data); + this.log('Processing initial data:', data); if (!data.nodes_table) { - console.warn('No nodes table in initial data'); + this.warn('No nodes table in initial data'); return; } @@ -269,7 +305,7 @@ class Monitor { // First pass: create all nodes and track offline nodes data.nodes_table.forEach(node => { try { - console.log('Processing node:', node); + this.log('Processing node:', node); const nodeData = { uid: node[0], idx: node[1], @@ -286,11 +322,11 @@ class Monitor { status: node[14] }; - console.log('Processed node data:', nodeData); + this.log('Processed node data:', nodeData); // Validate coordinates if (isNaN(nodeData.latitude) || isNaN(nodeData.longitude)) { - console.warn('Invalid coordinates in initial data for node:', nodeData.uid); + this.warn('Invalid coordinates in initial data for node:', nodeData.uid); // Use default coordinates if invalid nodeData.latitude = 38.023522; nodeData.longitude = -1.174389; @@ -299,7 +335,7 @@ class Monitor { // Track offline nodes if (!nodeData.status) { this.offlineNodes.add(nodeData.ip); - console.log('Node marked as offline during initialization:', nodeData.ip); + this.log('Node marked as offline during initialization:', nodeData.ip); } // Set initial timestamp @@ -311,7 +347,7 @@ class Monitor { // Update map this.updateQueue.push(nodeData); - console.log('Added node to update queue:', nodeData.uid); + this.log('Added node to update queue:', nodeData.uid); // Add node to graph data - ensure uniqueness const uniqueNodeId = `${nodeData.ip}:${nodeData.port}`; @@ -325,48 +361,48 @@ class Monitor { malicious: nodeData.malicious, color: this.getNodeColor({ ipport: uniqueNodeId, role: nodeData.role, malicious: nodeData.malicious }) }); - console.log('Added unique node:', uniqueNodeId); + this.log('Added unique node:', uniqueNodeId); } else { - console.log('Skipping duplicate node:', uniqueNodeId); + this.log('Skipping duplicate node:', uniqueNodeId); } } catch (error) { - console.error('Error processing node data:', error); + this.error('Error processing node data:', error); } }); // Convert unique nodes map to array this.gData.nodes = Array.from(uniqueNodes.values()); - console.log('Total unique nodes:', this.gData.nodes.length); + this.log('Total unique nodes:', this.gData.nodes.length); // Second pass: create links only between online nodes - console.log('Creating graph with', this.gData.nodes.length, 'nodes'); + this.log('Creating graph with', this.gData.nodes.length, 'nodes'); for (let i = 0; i < this.gData.nodes.length; i++) { const sourceNode = this.gData.nodes[i]; const sourceIP = sourceNode.ip; - + // Skip if source node is offline if (this.offlineNodes.has(sourceIP)) { - console.log('Skipping links for offline source node:', sourceIP); + this.log('Skipping links for offline source node:', sourceIP); continue; } for (let j = i + 1; j < this.gData.nodes.length; j++) { const targetNode = this.gData.nodes[j]; const targetIP = targetNode.ip; - + // Skip if target node is offline if (this.offlineNodes.has(targetIP)) { - console.log('Skipping link to offline target node:', targetIP); + this.log('Skipping link to offline target node:', targetIP); continue; } - + // Add bidirectional links only between online nodes this.gData.links.push({ source: sourceNode.ipport, target: targetNode.ipport, value: this.randomFloatFromInterval(1.0, 1.3) }); - + this.gData.links.push({ source: targetNode.ipport, target: sourceNode.ipport, @@ -377,20 +413,16 @@ class Monitor { // Process queue immediately this.processQueue(); - + // Initial graph update this.updateGraph(); - console.log('Initial data processing complete. Total links:', this.gData.links.length); + this.log('Initial data processing complete. Total links:', this.gData.links.length); } updateGraphData(data) { const nodeId = `${data.ip}:${data.port}`; - console.log('Updating graph data for node:', nodeId); - console.log('Current graph data:', { - nodes: this.gData.nodes, - links: this.gData.links - }); - + this.log('Updating graph data for node:', nodeId); + // Add or update node - ensure no duplication const existingNodeIndex = this.gData.nodes.findIndex(n => n.ipport === nodeId); if (existingNodeIndex === -1) { @@ -404,7 +436,7 @@ class Monitor { malicious: data.malicious, color: this.getNodeColor({ ipport: nodeId, role: data.role, malicious: data.malicious }) }; - console.log('Adding new node to graph:', newNode); + this.log('Adding new node to graph:', newNode); this.gData.nodes.push(newNode); } else { // Update existing node @@ -414,34 +446,17 @@ class Monitor { malicious: data.malicious, color: this.getNodeColor({ ipport: nodeId, role: data.role, malicious: data.malicious }) }; - console.log('Updating existing node in graph:', updatedNode); + this.log('Updating existing node in graph:', updatedNode); this.gData.nodes[existingNodeIndex] = updatedNode; } - // Helper function to get IP from source/target - const getIPFromLink = (linkEnd) => { - if (typeof linkEnd === 'string') { - return linkEnd.split(':')[0]; - } else if (linkEnd && typeof linkEnd === 'object') { - return linkEnd.ipport ? linkEnd.ipport.split(':')[0] : linkEnd.ip; - } - return ''; - }; - - // Normalize all existing links to use string IDs - this.gData.links = this.gData.links.map(link => ({ - source: typeof link.source === 'object' ? link.source.ipport : link.source, - target: typeof link.target === 'object' ? link.target.ipport : link.target, - value: link.value - })); - // If node is offline, remove its links but preserve others if (!data.status || this.offlineNodes.has(data.ip)) { - console.log('Node is offline, removing its links'); + this.log('Node is offline, removing its links'); this.gData.links = this.gData.links.filter(link => { - const sourceIP = getIPFromLink(link.source); - const targetIP = getIPFromLink(link.target); - return sourceIP !== data.ip && targetIP !== data.ip; + const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source; + const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target; + return sourceIP !== nodeId && targetIP !== nodeId; }); return; } @@ -450,73 +465,65 @@ class Monitor { if (data.neighbors) { // Parse neighbors using consistent format const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== ''); - console.log('Processing neighbors:', neighbors); - + this.log('Processing neighbors:', neighbors); + // Get current links for this node const currentLinks = this.gData.links.filter(link => { - const sourceIP = getIPFromLink(link.source); - const targetIP = getIPFromLink(link.target); - return sourceIP === data.ip || targetIP === data.ip; - }); - - // Remove links to offline neighbors - this.gData.links = this.gData.links.filter(link => { - if (getIPFromLink(link.source) === data.ip || getIPFromLink(link.target) === data.ip) { - const neighborId = getIPFromLink(link.source) === data.ip ? link.target : link.source; - const neighborIP = getIPFromLink(neighborId); - return !this.offlineNodes.has(neighborIP); - } - return true; + const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source; + const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target; + return sourceIP === nodeId || targetIP === nodeId; }); - // Add new links to online neighbors - neighbors.forEach(neighbor => { - const neighborIP = neighbor.split(':')[0]; - if (this.offlineNodes.has(neighborIP)) { - console.log('Skipping offline neighbor:', neighbor); - return; - } + // Create a set of current neighbor IDs + const currentNeighbors = new Set( + currentLinks.map(link => { + const neighborId = link.source === nodeId ? link.target : link.source; + return neighborId; + }) + ); - const normalizedNeighbor = neighbor.includes(':') ? neighbor : `${neighbor}:${data.port}`; - const neighborNode = this.gData.nodes.find(n => - n.ipport === normalizedNeighbor || - n.ipport.split(':')[0] === neighborIP - ); - - if (neighborNode) { - // Check if link already exists - const linkExists = this.gData.links.some(link => { - const sourceIP = getIPFromLink(link.source); - const targetIP = getIPFromLink(link.target); - return (sourceIP === data.ip && targetIP === neighborIP) || - (sourceIP === neighborIP && targetIP === data.ip); - }); + // Create a set of new neighbor IDs + const newNeighbors = new Set( + neighbors.map(neighbor => { + const [neighborIP, neighborPort] = neighbor.split(':'); + return `${neighborIP}:${neighborPort || data.port}`; + }) + ); - if (!linkExists) { - console.log('Adding new link between', nodeId, 'and', normalizedNeighbor); - this.gData.links.push({ - source: nodeId, - target: normalizedNeighbor, - value: this.randomFloatFromInterval(1.0, 1.3) - }); - } - } - }); - } + // Only update if there are actual changes in neighbors + if (!this.areSetsEqual(currentNeighbors, newNeighbors)) { + this.log('Neighbor changes detected, updating links'); - // Remove duplicate links - this.gData.links = this.gData.links.filter((link, index, self) => - index === self.findIndex((l) => { - const sourceIP1 = getIPFromLink(link.source); - const targetIP1 = getIPFromLink(link.target); - const sourceIP2 = getIPFromLink(l.source); - const targetIP2 = getIPFromLink(l.target); - return (sourceIP1 === sourceIP2 && targetIP1 === targetIP2) || - (sourceIP1 === targetIP2 && targetIP1 === sourceIP2); - }) - ); + // Remove existing links for this node + this.gData.links = this.gData.links.filter(link => { + const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source; + const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target; + return sourceIP !== nodeId && targetIP !== nodeId; + }); + + // Add new links for online neighbors + neighbors.forEach(neighbor => { + const neighborIP = neighbor.split(':')[0]; + if (!this.offlineNodes.has(neighborIP)) { + const normalizedNeighbor = neighbor.includes(':') ? neighbor : `${neighbor}:${data.port}`; + const neighborNode = this.gData.nodes.find(n => + n.ipport === normalizedNeighbor || + n.ipport.split(':')[0] === neighborIP + ); - console.log('Final links after update:', this.gData.links); + if (neighborNode) { + this.gData.links.push({ + source: nodeId, + target: normalizedNeighbor, + value: 1.0 + }); + } + } + }); + } else { + this.log('No neighbor changes detected, skipping link update'); + } + } } randomFloatFromInterval(min, max) { @@ -541,9 +548,9 @@ class Monitor { transparent: true, opacity: 0.8, }); - + const sphere = new THREE.Mesh( - new THREE.SphereGeometry(sphereRadius, 32, 32), + new THREE.SphereGeometry(sphereRadius, 32, 32), material ); group.add(sphere); @@ -568,12 +575,12 @@ class Monitor { if (this.offlineNodes.has(node.ip)) { return '#ff0000'; // Red color for offline nodes } - + // Check if the node is malicious if (node.malicious === "True" || node.malicious === "true") { return '#000000'; // Black color for malicious nodes } - + switch(node.role) { case 'trainer': return '#7570b3'; case 'aggregator': return '#d95f02'; @@ -610,26 +617,32 @@ class Monitor { this.handleNodeRemove(data); break; case 'control': - console.log('Control message received:', data); + this.log('Control message received:', data); break; default: - console.log('Unknown message type:', data.type); + this.log('Unknown message type:', data.type); } } catch (e) { - console.error('Error parsing WebSocket message:', e); + this.error('Error parsing WebSocket message:', e); } }); } handleNodeUpdate(data) { try { + // Skip updates if initial data is not loaded yet + if (!this.isInitialDataLoaded) { + this.log('Skipping node update - initial data not loaded yet'); + return; + } + // Validate required fields if (!data.uid || !data.ip) { - console.warn('Missing required fields for node update:', data); + this.warn('Missing required fields for node update:', data); return; } - console.log('Handling node update:', data); + this.log('Handling node update:', data); // Update timestamp for this node const nodeId = `${data.ip}:${data.port}`; @@ -638,71 +651,24 @@ class Monitor { // First update the table to show changes immediately this.updateNode(data); - // Update graph data - const existingNodeIndex = this.gData.nodes.findIndex(n => n.ipport === nodeId); - if (existingNodeIndex === -1) { - this.gData.nodes.push({ - id: data.idx, - ip: data.ip, - port: data.port, - ipport: nodeId, - role: data.role, - malicious: data.malicious, - color: this.getNodeColor({ ipport: nodeId, role: data.role, malicious: data.malicious }) - }); - } else { - this.gData.nodes[existingNodeIndex] = { - ...this.gData.nodes[existingNodeIndex], - role: data.role, - malicious: data.malicious, - color: this.getNodeColor({ ipport: nodeId, role: data.role, malicious: data.malicious }) - }; - } - - // Handle links - if (data.neighbors) { - const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== ''); - - // Remove existing links for this node - this.gData.links = this.gData.links.filter(link => { - const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source; - const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target; - return sourceIP !== nodeId && targetIP !== nodeId; - }); - - // Add new links for online neighbors - neighbors.forEach(neighbor => { - const neighborIP = neighbor.split(':')[0]; - if (!this.offlineNodes.has(neighborIP)) { - const normalizedNeighbor = neighbor.includes(':') ? neighbor : `${neighbor}:${data.port}`; - const neighborNode = this.gData.nodes.find(n => - n.ipport === normalizedNeighbor || - n.ipport.split(':')[0] === neighborIP - ); - - if (neighborNode) { - this.gData.links.push({ - source: nodeId, - target: normalizedNeighbor, - value: this.randomFloatFromInterval(1.0, 1.3) - }); - } - } - }); - } + // Update graph data with new topology information + this.updateGraphData(data); - // Update the graph + // Queue the graph update this.updateGraph(); - // Process map updates immediately - this.processUpdate(data); + // Process map updates immediately with neighbor distances + this.processUpdate({ + ...data, + neighbors_distance: data.neighbors_distance || {} + }); // Check if all nodes are offline this.checkAllNodesOffline(); - console.log('Node update completed successfully'); + this.log('Node update completed successfully'); } catch (error) { - console.error('Error handling node update:', error); + this.error('Error handling node update:', error); } } @@ -711,15 +677,15 @@ class Monitor { if (!data) return false; const nodeId = `${data.ip}:${data.port}`; - const currentLinks = this.gData.links.filter(link => + const currentLinks = this.gData.links.filter(link => link.source === nodeId || link.target === nodeId ); - + if (!data.neighbors) return false; - + // Parse neighbors using consistent format const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== ''); - + // Create sets of current and new neighbors for comparison const currentNeighbors = new Set( currentLinks.map(link => { @@ -727,18 +693,18 @@ class Monitor { return neighborId.split(':')[0]; // Compare only IPs }) ); - + const newNeighbors = new Set( neighbors.map(neighbor => neighbor.split(':')[0]) // Compare only IPs ); - + // Check if there are any differences in the sets if (currentNeighbors.size !== newNeighbors.size) return true; - + for (const neighbor of newNeighbors) { if (!currentNeighbors.has(neighbor)) return true; } - + return false; } @@ -746,7 +712,7 @@ class Monitor { try { // Validate required fields if (!data.uid || !data.ip) { - console.warn('Missing required fields for node removal:', data); + this.warn('Missing required fields for node removal:', data); return; } @@ -756,31 +722,31 @@ class Monitor { this.updateGraphData(data); this.updateGraph(); } catch (error) { - console.error('Error handling node removal:', error); + this.error('Error handling node removal:', error); } } updateNode(data) { if (!data || !data.uid) { - console.warn('Invalid or missing data for node update:', data); + this.warn('Invalid or missing data for node update:', data); return; } let nodeRow = document.querySelector(`#node-${data.uid}`); - + // If row doesn't exist, create it if (!nodeRow) { - console.log('Creating new row for node:', data.uid); + this.log('Creating new row for node:', data.uid); const tableBody = document.querySelector('#table-nodes tbody'); if (!tableBody) { - console.error('Table body not found'); + this.error('Table body not found'); return; } // Create new row nodeRow = document.createElement('tr'); nodeRow.id = `node-${data.uid}`; - + // Create cells matching the HTML template structure const cells = [ { class: 'py-3', content: '' }, // IDX @@ -802,7 +768,7 @@ class Monitor { // Add row to table tableBody.appendChild(nodeRow); - console.log('New row created for node:', data.uid); + this.log('New row created for node:', data.uid); } const nodeId = `${data.ip}:${data.port}`; // Use full IP:port as nodeId @@ -812,15 +778,15 @@ class Monitor { // Update offlineNodes set based on status if (isNowOffline) { this.offlineNodes.add(nodeId); - console.log('Node marked as offline:', nodeId); - + this.log('Node marked as offline:', nodeId); + // Remove all links for this node this.removeNodeLinks(data); - + // Force immediate graph update when node goes offline this.updateGraphData(data); this.updateGraph(); - + // Update marker appearance if (this.droneMarkers[data.uid]) { this.droneMarkers[data.uid].setIcon(this.droneIconOffline); @@ -828,8 +794,8 @@ class Monitor { } } else { this.offlineNodes.delete(nodeId); - console.log('Node marked as online:', nodeId); - + this.log('Node marked as online:', nodeId); + // Update marker appearance if (this.droneMarkers[data.uid]) { this.droneMarkers[data.uid].setIcon(this.droneIcon); @@ -878,7 +844,7 @@ class Monitor { // Update Status const statusCell = nodeRow.querySelector('td:nth-child(6)'); if (statusCell) { - statusCell.innerHTML = data.status + statusCell.innerHTML = data.status ? 'Online' : 'Offline'; } @@ -893,7 +859,7 @@ class Monitor { ` : ''; - + actionsCell.innerHTML = ` {% endif %} - + {% if scenario_running %}
-
{{ scenarios_finished }}/{{ scenarios_list_length }}
{% if scenarios_finished != scenarios_list_length %} - Stop Queue @@ -74,25 +74,26 @@
Scenario Name
-

{{ scenario_running[0] }}

+

{{ scenario_running.name }}

Title
-

{{ scenario_running[3] }}

+

{{ scenario_running.title }}

-
Description
-

{{ scenario_running[4] }}

+
Description +
+

{{ scenario_running.description }}

Start Time
-

{{ scenario_running[1] }}

+

{{ scenario_running.start_time }}

@@ -130,10 +131,11 @@
Status
-

Get started by deploying your first scenario to begin monitoring and analyzing your federated learning process.

+

Get started by deploying your first scenario to begin monitoring + and analyzing your federated learning process.

- Deploy a Scenario @@ -175,23 +177,22 @@
Status
- {% for name, start_time, end_time, title, description, status, network_subnet, model, dataset, - rounds, role, username, gpu_id in scenarios %} + {% for scenario in scenarios %} {% if user_role == "admin" %} - {{ username|lower }} + {{ scenario.username|lower }} {% endif %} - {{ title }} - {{ start_time }} - {{ model }} - {{ dataset }} - {{ rounds }} + {{ scenario.title }} + {{ scenario.start_time }} + {{ scenario.model }} + {{ scenario.dataset }} + {{ scenario.rounds }} - {% if status == "running" %} + {% if scenario.status == "running" %} Running - {% elif status == "completed" %} + {% elif scenario.status == "completed" %} Completed @@ -203,65 +204,71 @@
Status
- - - - - {% if status == "running" %} - - {% elif status == "completed" %} - - {% else %} - - {% endif %}
- +
- -
- + - + {% endfor %} @@ -276,16 +283,12 @@
Status
{% endif %} - -{% endblock %} + {% endblock %} \ No newline at end of file diff --git a/nebula/frontend/templates/layout.html b/nebula/frontend/templates/layout.html index d7bb8bd51..97671a59c 100755 --- a/nebula/frontend/templates/layout.html +++ b/nebula/frontend/templates/layout.html @@ -75,7 +75,7 @@ // Create the notification element const notification = document.createElement('div'); notification.classList.add('notification', category); - + // Set icon based on category let icon = ''; switch(category) { @@ -92,7 +92,7 @@ icon = 'fa-info-circle'; break; } - + // Create notification content notification.innerHTML = ` @@ -104,11 +104,11 @@ `; - + // Add to container const container = document.getElementById('notification-container'); container.appendChild(notification); - + // Add click handler for close button const closeBtn = notification.querySelector('.close'); closeBtn.addEventListener('click', () => { @@ -117,7 +117,7 @@ notification.remove(); }, 300); }); - + // Auto remove after 5 seconds setTimeout(() => { if (notification.parentElement) { diff --git a/nebula/frontend/templates/monitor.html b/nebula/frontend/templates/monitor.html index ac8f6aaba..f4f7b04cf 100755 --- a/nebula/frontend/templates/monitor.html +++ b/nebula/frontend/templates/monitor.html @@ -35,30 +35,30 @@
Status
- {% if scenario[5] == "running" %} + {% if scenario[43] == "running" %} Running - {% elif scenario[5] == "completed" %} + {% elif scenario[43] == "completed" %} Completed - {% else %} + {% else %} Finished - {% endif %} + {% endif %}
- +
- {% if scenario[5] == "running" or scenario[5] == "completed" %} - Stop scenario - {% endif %} - Real-time metrics @@ -68,9 +68,9 @@
class="btn btn-outline-dark w-100"> Logs -
+
- Metrics @@ -84,9 +84,9 @@
- +
-
+
@@ -97,12 +97,13 @@
- - + + - {% for uid, idx, ip, port, role, neighbors, latitude, longitude, timestamp, federation, round, + {% for uid, idx, ip, port, role, neighbors, latitude, longitude, timestamp, + federation, round, scenario, hash, malicious, status in nodes %} - + - - {% endfor %} + + +
  • + + Download + ERROR logs + +
  • + + + + + {% endfor %} -
    Behaviour Status Actions
    {{ idx }} {{ ip }} @@ -112,63 +113,68 @@
    {{ round }} - {% if malicious == "True" %} + {% if malicious == "True" %} Malicious - {% else %} + {% else %} Benign - {% endif %} + {% endif %} - {% if status %} + {% if status %} Online - {% else %} + {% else %} Offline - {% endif %} + {% endif %} - -
    +
    @@ -199,7 +205,7 @@

    - Visualize the scenario topology. + Visualize the scenario topology. Download topology @@ -218,13 +224,13 @@

    #map { height: 600px; border-radius: 8px; - border: 1px solid rgba(0,0,0,0.1); + border: 1px solid rgba(0, 0, 0, 0.1); } #3d-graph { height: 600px; border-radius: 8px; - border: 1px solid rgba(0,0,0,0.1); + border: 1px solid rgba(0, 0, 0, 0.1); } .drone-offline { @@ -232,10 +238,10 @@
    } .scenario-info { - background: rgba(255,255,255,0.9); + background: rgba(255, 255, 255, 0.9); padding: 2rem; border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .no-scenario-alert { @@ -245,7 +251,7 @@
    border: 2px dashed #dee2e6; } - .table > :not(caption) > * > * { + .table> :not(caption)>*>* { padding: 1rem; } @@ -296,7 +302,7 @@
    {% endif %} - + {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/nebula/tests/aggregation.json b/nebula/tests/aggregation.json deleted file mode 100644 index 28da89009..000000000 --- a/nebula/tests/aggregation.json +++ /dev/null @@ -1,1327 +0,0 @@ -[ - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 4.098306148944383, - "y": 23.173221369798007, - "z": -28.19759352739643, - "vx": -2.0831031308992298e-21, - "vy": 1.754804160005046e-21, - "vz": 8.030997683663338e-22 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -21.679439525445115, - "y": -20.242793933733992, - "z": -20.91487664228227, - "vx": 2.108797849477679e-21, - "vy": -2.6498307045524958e-21, - "vz": 8.083173941493192e-22 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -26.827482443217484, - "y": 1.565949917972576, - "z": 25.123211545331383, - "vx": -3.6280633629224664e-22, - "vy": 2.7527181683577277e-21, - "vz": -1.6140581291246547e-21 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.659976773415096, - "y": 22.728956643461622, - "z": 20.447956485051815, - "vx": 1.249683602790581e-21, - "vy": -3.3222961856099976e-21, - "vz": 2.542487771413768e-22 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.74863904630312, - "y": -27.22533399749821, - "z": 3.541302139295502, - "vx": -9.125719850767656e-22, - "vy": 1.4646045617996968e-21, - "vz": -2.516078105323674e-22 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "Krum_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.8559076246335797, - "y": 23.429440856467895, - "z": -28.053858451864002, - "vx": -2.8652417643016315e-9, - "vy": 3.3786023326029493e-9, - "vz": 1.214786579425734e-9 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.843731057225405, - "y": -20.94151769409688, - "z": -21.066523454396382, - "vx": 4.2864764819971485e-9, - "vy": -6.225416422297865e-9, - "vz": 3.6101536654790506e-9 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.569301823577973, - "y": 2.6945969099841216, - "z": 24.261294462379315, - "vx": 1.136683013490489e-9, - "vy": 6.084923071907774e-9, - "vz": -3.967376107314338e-9 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.12799264004355, - "y": 22.166918448844807, - "z": 20.59758920673133, - "vx": 4.0919589895344724e-10, - "vy": -9.406539348269888e-9, - "vz": 1.0280391752334319e-9 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.42913261612625, - "y": -27.349438521199943, - "z": 4.261498237149738, - "vx": -2.9671136301394138e-9, - "vy": 6.168430366057071e-9, - "vz": -1.8856033128238704e-9 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "Krum", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "TrimmedMean_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.8559078106306224, - "y": 23.42944063711579, - "z": -28.053858530696406, - "vx": -7.208131986268984e-9, - "vy": 8.499604058047907e-9, - "vz": 3.0560575969033323e-9 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.843731335566332, - "y": -20.941517289772914, - "z": -21.0665236889446, - "vx": 1.0783559780205782e-8, - "vy": -1.5661381575058224e-8, - "vz": 9.082120638398062e-9 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.56930189750702, - "y": 2.6945965148689033, - "z": 24.26129472000483, - "vx": 2.8595705185823904e-9, - "vy": 1.5307940826230038e-8, - "vz": -9.9807947821713e-9 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.1279926136184, - "y": 22.166919059741822, - "z": 20.597589139985548, - "vx": 1.0294190943442794e-9, - "vy": -2.3664183501389124e-8, - "vz": 2.586255639561701e-9 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.42913280882433, - "y": -27.349438921953602, - "z": 4.261498359650628, - "vx": -7.464417406863437e-9, - "vy": 1.5518020192169336e-8, - "vz": -4.74363909269172e-9 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "TrimmedMean", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "Median_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.8559075016925686, - "y": 23.429441001405863, - "z": -28.053858399714905, - "vx": -5.835949924731824e-16, - "vy": 6.881565626347889e-16, - "vz": 2.4742877693692936e-16, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "654e6532-070f-463e-8117-17cdfe60328c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "8ef94f86-2ed7-479d-99b6-8917ab538c5c", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a720d718-65ea-4076-a946-815a96546667", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 3.8559075016925686, - 23.429441001405863, - -28.053858399714905, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "654e6532-070f-463e-8117-17cdfe60328c", - "material": "8ef94f86-2ed7-479d-99b6-8917ab538c5c" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.84373087338685, - "y": -20.941517961014213, - "z": -21.066523299688075, - "vx": 8.730730166209849e-16, - "vy": -1.2679979600970866e-15, - "vz": 7.353191768361504e-16, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "d9161751-0857-45b2-970f-e26f46a497fe", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "144c079b-6461-49d7-bc23-9a92ccd5d0ce", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "b8d7de50-8643-435d-be4c-0332ed56e519", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.84373087338685, - -20.941517961014213, - -21.066523299688075, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "d9161751-0857-45b2-970f-e26f46a497fe", - "material": "144c079b-6461-49d7-bc23-9a92ccd5d0ce" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.569301774946823, - "y": 2.6945971709618024, - "z": 24.261294292231614, - "vx": 2.315206139555819e-16, - "vy": 1.239382122936372e-15, - "vz": -8.080783344602377e-16, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "584cdf07-d37b-41f4-a915-6a07acccd0a5", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "3d9d563c-ff97-4466-b718-d941fb0998d9", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "b7c15844-567a-49b6-9be1-fd3cfd8418f5", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -27.569301774946823, - 2.6945971709618024, - 24.261294292231614, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "584cdf07-d37b-41f4-a915-6a07acccd0a5", - "material": "3d9d563c-ff97-4466-b718-d941fb0998d9" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.12799265773897, - "y": 22.166918045503245, - "z": 20.59758925083149, - "vx": 8.334544917133531e-17, - "vy": -1.9159317527180463e-15, - "vz": 2.0939182966055774e-16, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "55432763-2b17-4803-9abb-8c8dcf8225e6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "8cb3d7ad-23d7-48d9-a496-4106741c17e5", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "652232d0-b626-4d6e-8c5b-44182f73ece1", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 20.12799265773897, - 22.166918045503245, - 20.59758925083149, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "55432763-2b17-4803-9abb-8c8dcf8225e6", - "material": "8cb3d7ad-23d7-48d9-a496-4106741c17e5" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.429132488902127, - "y": -27.3494382568567, - "z": 4.2614981563398775, - "vx": -6.043440872747319e-16, - "vy": 1.2563910272439652e-15, - "vz": -3.8406144897340187e-16, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "7eccd3c6-80c7-47e7-8f47-eba694f7718b", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "520ed304-cf65-4f68-90b4-81044ef928cb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "14dc24c5-d50a-4665-856b-da627783e9bb", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 24.429132488902127, - -27.3494382568567, - 4.2614981563398775, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "7eccd3c6-80c7-47e7-8f47-eba694f7718b", - "material": "520ed304-cf65-4f68-90b4-81044ef928cb" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "Median", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - } -] diff --git a/nebula/tests/attacks.json b/nebula/tests/attacks.json deleted file mode 100644 index b90bbbe74..000000000 --- a/nebula/tests/attacks.json +++ /dev/null @@ -1,3027 +0,0 @@ -[ - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 5.631418365613239, - "y": 22.694610891010647, - "z": -28.360725118467528, - "vx": -1.0328046273358557e-8, - "vy": 1.5929041989459476e-8, - "vz": 3.908696708641432e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "9937f075-faa3-4676-8b54-756622fd8981", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "52abc64e-91e6-4086-b939-4b34847cb7f0", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a4edcb16-67e9-4d55-8380-bd22c205ad96", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 5.631418365613239, - 22.694610891010647, - -28.360725118467528, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "9937f075-faa3-4676-8b54-756622fd8981", - "material": "52abc64e-91e6-4086-b939-4b34847cb7f0" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.140462201825898, - "y": -21.13417074227967, - "z": -21.551885000578707, - "vx": 1.8458080528715605e-8, - "vy": -3.116710371387466e-8, - "vz": 2.2704912480424454e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "9f3edff2-2806-4f30-8948-4287450b6915", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "5d13f03c-c2bf-40c8-8eda-6eb33a0da6e6", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.140462201825898, - -21.13417074227967, - -21.551885000578707, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "material": "9f3edff2-2806-4f30-8948-4287450b6915" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.71703280728614, - "y": 4.020987712595549, - "z": 22.705010362753697, - "vx": 1.0694677449996208e-8, - "vy": 2.9084972037089048e-8, - "vz": -2.015565459223841e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "7c040637-10f0-408b-8975-0338903659fb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "44f3c661-2ccd-440c-827b-ffcc489b5c64", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -28.71703280728614, - 4.020987712595549, - 22.705010362753697, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "material": "7c040637-10f0-408b-8975-0338903659fb" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.679145143125147, - "y": 22.16282780436268, - "z": 21.030079148818903, - "vx": -3.852416481366844e-9, - "vy": -4.9742524279957165e-8, - "vz": 6.992962533560291e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "75c95712-2b2f-44e0-84b1-2130d887113c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f54d2ff7-d369-4e22-b285-ca6cdeb23421", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "142323c7-c3b1-4d07-860b-a623114b67ad", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.679145143125147, - 22.16282780436268, - 21.030079148818903, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "75c95712-2b2f-44e0-84b1-2130d887113c", - "material": "f54d2ff7-d369-4e22-b285-ca6cdeb23421" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.546931500373656, - "y": -27.744255665689202, - "z": 6.177520607473647, - "vx": -1.4972295223986403e-8, - "vy": 3.58956139672832e-8, - "vz": -1.3450917130387546e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "13def5d0-adf5-4e3b-bb57-30362a221818", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "64c9ea9c-1c99-420a-b048-81aead906df3", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 23.546931500373656, - -27.744255665689202, - 6.177520607473647, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "material": "13def5d0-adf5-4e3b-bb57-30362a221818" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": false, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_GLLNeuronInversionAttack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 5.631418365613239, - "y": 22.694610891010647, - "z": -28.360725118467528, - "vx": -1.0328046273358557e-8, - "vy": 1.5929041989459476e-8, - "vz": 3.908696708641432e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "9937f075-faa3-4676-8b54-756622fd8981", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "52abc64e-91e6-4086-b939-4b34847cb7f0", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a4edcb16-67e9-4d55-8380-bd22c205ad96", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 5.631418365613239, - 22.694610891010647, - -28.360725118467528, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "9937f075-faa3-4676-8b54-756622fd8981", - "material": "52abc64e-91e6-4086-b939-4b34847cb7f0" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.140462201825898, - "y": -21.13417074227967, - "z": -21.551885000578707, - "vx": 1.8458080528715605e-8, - "vy": -3.116710371387466e-8, - "vz": 2.2704912480424454e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "9f3edff2-2806-4f30-8948-4287450b6915", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "5d13f03c-c2bf-40c8-8eda-6eb33a0da6e6", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.140462201825898, - -21.13417074227967, - -21.551885000578707, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "material": "9f3edff2-2806-4f30-8948-4287450b6915" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.71703280728614, - "y": 4.020987712595549, - "z": 22.705010362753697, - "vx": 1.0694677449996208e-8, - "vy": 2.9084972037089048e-8, - "vz": -2.015565459223841e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "7c040637-10f0-408b-8975-0338903659fb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "44f3c661-2ccd-440c-827b-ffcc489b5c64", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -28.71703280728614, - 4.020987712595549, - 22.705010362753697, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "material": "7c040637-10f0-408b-8975-0338903659fb" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.679145143125147, - "y": 22.16282780436268, - "z": 21.030079148818903, - "vx": -3.852416481366844e-9, - "vy": -4.9742524279957165e-8, - "vz": 6.992962533560291e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "75c95712-2b2f-44e0-84b1-2130d887113c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f54d2ff7-d369-4e22-b285-ca6cdeb23421", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "142323c7-c3b1-4d07-860b-a623114b67ad", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.679145143125147, - 22.16282780436268, - 21.030079148818903, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "75c95712-2b2f-44e0-84b1-2130d887113c", - "material": "f54d2ff7-d369-4e22-b285-ca6cdeb23421" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.546931500373656, - "y": -27.744255665689202, - "z": 6.177520607473647, - "vx": -1.4972295223986403e-8, - "vy": 3.58956139672832e-8, - "vz": -1.3450917130387546e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "13def5d0-adf5-4e3b-bb57-30362a221818", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "64c9ea9c-1c99-420a-b048-81aead906df3", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 23.546931500373656, - -27.744255665689202, - 6.177520607473647, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "material": "13def5d0-adf5-4e3b-bb57-30362a221818" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "GLLNeuronInversionAttack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_NoiseInjectionAttack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 5.631418365613239, - "y": 22.694610891010647, - "z": -28.360725118467528, - "vx": -1.0328046273358557e-8, - "vy": 1.5929041989459476e-8, - "vz": 3.908696708641432e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "9937f075-faa3-4676-8b54-756622fd8981", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "52abc64e-91e6-4086-b939-4b34847cb7f0", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a4edcb16-67e9-4d55-8380-bd22c205ad96", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 5.631418365613239, - 22.694610891010647, - -28.360725118467528, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "9937f075-faa3-4676-8b54-756622fd8981", - "material": "52abc64e-91e6-4086-b939-4b34847cb7f0" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.140462201825898, - "y": -21.13417074227967, - "z": -21.551885000578707, - "vx": 1.8458080528715605e-8, - "vy": -3.116710371387466e-8, - "vz": 2.2704912480424454e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "9f3edff2-2806-4f30-8948-4287450b6915", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "5d13f03c-c2bf-40c8-8eda-6eb33a0da6e6", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.140462201825898, - -21.13417074227967, - -21.551885000578707, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "material": "9f3edff2-2806-4f30-8948-4287450b6915" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.71703280728614, - "y": 4.020987712595549, - "z": 22.705010362753697, - "vx": 1.0694677449996208e-8, - "vy": 2.9084972037089048e-8, - "vz": -2.015565459223841e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "7c040637-10f0-408b-8975-0338903659fb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "44f3c661-2ccd-440c-827b-ffcc489b5c64", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -28.71703280728614, - 4.020987712595549, - 22.705010362753697, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "material": "7c040637-10f0-408b-8975-0338903659fb" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.679145143125147, - "y": 22.16282780436268, - "z": 21.030079148818903, - "vx": -3.852416481366844e-9, - "vy": -4.9742524279957165e-8, - "vz": 6.992962533560291e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "75c95712-2b2f-44e0-84b1-2130d887113c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f54d2ff7-d369-4e22-b285-ca6cdeb23421", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "142323c7-c3b1-4d07-860b-a623114b67ad", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.679145143125147, - 22.16282780436268, - 21.030079148818903, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "75c95712-2b2f-44e0-84b1-2130d887113c", - "material": "f54d2ff7-d369-4e22-b285-ca6cdeb23421" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.546931500373656, - "y": -27.744255665689202, - "z": 6.177520607473647, - "vx": -1.4972295223986403e-8, - "vy": 3.58956139672832e-8, - "vz": -1.3450917130387546e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "13def5d0-adf5-4e3b-bb57-30362a221818", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "64c9ea9c-1c99-420a-b048-81aead906df3", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 23.546931500373656, - -27.744255665689202, - 6.177520607473647, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "material": "13def5d0-adf5-4e3b-bb57-30362a221818" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "NoiseInjectionAttack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_SwappingWeightsAttack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 5.631418365613239, - "y": 22.694610891010647, - "z": -28.360725118467528, - "vx": -1.0328046273358557e-8, - "vy": 1.5929041989459476e-8, - "vz": 3.908696708641432e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "9937f075-faa3-4676-8b54-756622fd8981", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "52abc64e-91e6-4086-b939-4b34847cb7f0", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a4edcb16-67e9-4d55-8380-bd22c205ad96", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 5.631418365613239, - 22.694610891010647, - -28.360725118467528, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "9937f075-faa3-4676-8b54-756622fd8981", - "material": "52abc64e-91e6-4086-b939-4b34847cb7f0" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.140462201825898, - "y": -21.13417074227967, - "z": -21.551885000578707, - "vx": 1.8458080528715605e-8, - "vy": -3.116710371387466e-8, - "vz": 2.2704912480424454e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "9f3edff2-2806-4f30-8948-4287450b6915", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "5d13f03c-c2bf-40c8-8eda-6eb33a0da6e6", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.140462201825898, - -21.13417074227967, - -21.551885000578707, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "material": "9f3edff2-2806-4f30-8948-4287450b6915" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.71703280728614, - "y": 4.020987712595549, - "z": 22.705010362753697, - "vx": 1.0694677449996208e-8, - "vy": 2.9084972037089048e-8, - "vz": -2.015565459223841e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "7c040637-10f0-408b-8975-0338903659fb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "44f3c661-2ccd-440c-827b-ffcc489b5c64", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -28.71703280728614, - 4.020987712595549, - 22.705010362753697, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "material": "7c040637-10f0-408b-8975-0338903659fb" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.679145143125147, - "y": 22.16282780436268, - "z": 21.030079148818903, - "vx": -3.852416481366844e-9, - "vy": -4.9742524279957165e-8, - "vz": 6.992962533560291e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "75c95712-2b2f-44e0-84b1-2130d887113c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f54d2ff7-d369-4e22-b285-ca6cdeb23421", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "142323c7-c3b1-4d07-860b-a623114b67ad", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.679145143125147, - 22.16282780436268, - 21.030079148818903, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "75c95712-2b2f-44e0-84b1-2130d887113c", - "material": "f54d2ff7-d369-4e22-b285-ca6cdeb23421" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.546931500373656, - "y": -27.744255665689202, - "z": 6.177520607473647, - "vx": -1.4972295223986403e-8, - "vy": 3.58956139672832e-8, - "vz": -1.3450917130387546e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "13def5d0-adf5-4e3b-bb57-30362a221818", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "64c9ea9c-1c99-420a-b048-81aead906df3", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 23.546931500373656, - -27.744255665689202, - 6.177520607473647, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "material": "13def5d0-adf5-4e3b-bb57-30362a221818" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "SwappingWeightsAttack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_DelayerAttack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 5.631418365613239, - "y": 22.694610891010647, - "z": -28.360725118467528, - "vx": -1.0328046273358557e-8, - "vy": 1.5929041989459476e-8, - "vz": 3.908696708641432e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "9937f075-faa3-4676-8b54-756622fd8981", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "52abc64e-91e6-4086-b939-4b34847cb7f0", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "a4edcb16-67e9-4d55-8380-bd22c205ad96", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 5.631418365613239, - 22.694610891010647, - -28.360725118467528, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "9937f075-faa3-4676-8b54-756622fd8981", - "material": "52abc64e-91e6-4086-b939-4b34847cb7f0" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.140462201825898, - "y": -21.13417074227967, - "z": -21.551885000578707, - "vx": 1.8458080528715605e-8, - "vy": -3.116710371387466e-8, - "vz": 2.2704912480424454e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "9f3edff2-2806-4f30-8948-4287450b6915", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "5d13f03c-c2bf-40c8-8eda-6eb33a0da6e6", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.140462201825898, - -21.13417074227967, - -21.551885000578707, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "95c670ff-2cc0-427f-a91f-e474bab505d6", - "material": "9f3edff2-2806-4f30-8948-4287450b6915" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.71703280728614, - "y": 4.020987712595549, - "z": 22.705010362753697, - "vx": 1.0694677449996208e-8, - "vy": 2.9084972037089048e-8, - "vz": -2.015565459223841e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "7c040637-10f0-408b-8975-0338903659fb", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "44f3c661-2ccd-440c-827b-ffcc489b5c64", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -28.71703280728614, - 4.020987712595549, - 22.705010362753697, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "26bf41c6-a855-4af4-9ebb-05bdf9d78bb4", - "material": "7c040637-10f0-408b-8975-0338903659fb" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.679145143125147, - "y": 22.16282780436268, - "z": 21.030079148818903, - "vx": -3.852416481366844e-9, - "vy": -4.9742524279957165e-8, - "vz": 6.992962533560291e-9, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "75c95712-2b2f-44e0-84b1-2130d887113c", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f54d2ff7-d369-4e22-b285-ca6cdeb23421", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "142323c7-c3b1-4d07-860b-a623114b67ad", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.679145143125147, - 22.16282780436268, - 21.030079148818903, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "75c95712-2b2f-44e0-84b1-2130d887113c", - "material": "f54d2ff7-d369-4e22-b285-ca6cdeb23421" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.546931500373656, - "y": -27.744255665689202, - "z": 6.177520607473647, - "vx": -1.4972295223986403e-8, - "vy": 3.58956139672832e-8, - "vz": -1.3450917130387546e-8, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "13def5d0-adf5-4e3b-bb57-30362a221818", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "64c9ea9c-1c99-420a-b048-81aead906df3", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 23.546931500373656, - -27.744255665689202, - 6.177520607473647, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "6593363c-6081-4c21-b1e9-3ae4f03b3a3d", - "material": "13def5d0-adf5-4e3b-bb57-30362a221818" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "DelayerAttack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - } -] diff --git a/nebula/tests/custom.json b/nebula/tests/custom.json deleted file mode 100644 index 76da35f4a..000000000 --- a/nebula/tests/custom.json +++ /dev/null @@ -1,1805 +0,0 @@ -[ - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_Label Flipping", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 19.78658547070387, - "y": 14.908003427732176, - "z": -27.15146341244569, - "vx": -2.0080631994256613e-17, - "vy": 4.148361505633457e-17, - "vz": -7.470336967988616e-18, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "4cefda57-6767-46b3-994d-2b492a25946f", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.78658547070387, - 14.908003427732176, - -27.15146341244569, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "material": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -14.95619339975917, - "y": -22.681452267203365, - "z": -24.055162701955332, - "vx": 6.861606928961974e-18, - "vy": -6.327386013782184e-17, - "vz": 7.362723888583972e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "cc6498f7-e281-45f2-ab6e-8a333f819761", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "007f72d2-ef7e-46cb-95ea-de52c50a4bfa", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -14.95619339975917, - -22.681452267203365, - -24.055162701955332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "material": "cc6498f7-e281-45f2-ab6e-8a333f819761" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -33.49778869290172, - "y": 12.470918740201123, - "z": 8.866807325597065, - "vx": 4.949730945188363e-17, - "vy": 5.24238285561063e-17, - "vz": -4.4791740293906743e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "758d7e77-0cb0-43cf-ac1d-fc0e9a313c0c", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -33.49778869290172, - 12.470918740201123, - 8.866807325597065, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "material": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 14.890533829357492, - "y": 23.692214096958434, - "z": 23.188142792285632, - "vx": -3.299456513301944e-17, - "vy": -1.0348221199743117e-16, - "vz": 3.052023238520353e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "cefabcbd-54e8-486d-8abc-699589fcd1bc", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 14.890533829357492, - 23.692214096958434, - 23.188142792285632, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "material": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 13.776862792599529, - "y": -28.38968399768837, - "z": 19.151675996518332, - "vx": -3.2837192535705342e-18, - "vy": 7.284862852281181e-17, - "vz": -5.188539400914919e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "006c8eb0-38aa-4477-81b1-c667b889e5cc", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "e204a9da-3b07-418b-9e3a-7a3e49f71711", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 13.776862792599529, - -28.38968399768837, - 19.151675996518332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "material": "006c8eb0-38aa-4477-81b1-c667b889e5cc" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "Label Flipping", - "poisoned_node_percent": "25", - "poisoned_sample_percent": "20", - "poisoned_noise_percent": "10", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_Sample Poisoning", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 19.78658547070387, - "y": 14.908003427732176, - "z": -27.15146341244569, - "vx": -2.0080631994256613e-17, - "vy": 4.148361505633457e-17, - "vz": -7.470336967988616e-18, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "4cefda57-6767-46b3-994d-2b492a25946f", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.78658547070387, - 14.908003427732176, - -27.15146341244569, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "material": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -14.95619339975917, - "y": -22.681452267203365, - "z": -24.055162701955332, - "vx": 6.861606928961974e-18, - "vy": -6.327386013782184e-17, - "vz": 7.362723888583972e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "cc6498f7-e281-45f2-ab6e-8a333f819761", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "007f72d2-ef7e-46cb-95ea-de52c50a4bfa", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -14.95619339975917, - -22.681452267203365, - -24.055162701955332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "material": "cc6498f7-e281-45f2-ab6e-8a333f819761" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -33.49778869290172, - "y": 12.470918740201123, - "z": 8.866807325597065, - "vx": 4.949730945188363e-17, - "vy": 5.24238285561063e-17, - "vz": -4.4791740293906743e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "758d7e77-0cb0-43cf-ac1d-fc0e9a313c0c", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -33.49778869290172, - 12.470918740201123, - 8.866807325597065, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "material": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 14.890533829357492, - "y": 23.692214096958434, - "z": 23.188142792285632, - "vx": -3.299456513301944e-17, - "vy": -1.0348221199743117e-16, - "vz": 3.052023238520353e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "cefabcbd-54e8-486d-8abc-699589fcd1bc", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 14.890533829357492, - 23.692214096958434, - 23.188142792285632, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "material": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 13.776862792599529, - "y": -28.38968399768837, - "z": 19.151675996518332, - "vx": -3.2837192535705342e-18, - "vy": 7.284862852281181e-17, - "vz": -5.188539400914919e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "006c8eb0-38aa-4477-81b1-c667b889e5cc", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "e204a9da-3b07-418b-9e3a-7a3e49f71711", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 13.776862792599529, - -28.38968399768837, - 19.151675996518332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "material": "006c8eb0-38aa-4477-81b1-c667b889e5cc" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "Sample Poisoning", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "20", - "poisoned_noise_percent": "10", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_Model Poisoning", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Custom", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 19.78658547070387, - "y": 14.908003427732176, - "z": -27.15146341244569, - "vx": -2.0080631994256613e-17, - "vy": 4.148361505633457e-17, - "vz": -7.470336967988616e-18, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "4cefda57-6767-46b3-994d-2b492a25946f", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 19.78658547070387, - 14.908003427732176, - -27.15146341244569, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "57f04756-bdd5-43ac-b62b-3c013b3be130", - "material": "4ec4c016-53f3-4a8c-b6e0-5ea1fa341950" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -14.95619339975917, - "y": -22.681452267203365, - "z": -24.055162701955332, - "vx": 6.861606928961974e-18, - "vy": -6.327386013782184e-17, - "vz": 7.362723888583972e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "cc6498f7-e281-45f2-ab6e-8a333f819761", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "007f72d2-ef7e-46cb-95ea-de52c50a4bfa", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -14.95619339975917, - -22.681452267203365, - -24.055162701955332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c3f4d13f-c3cb-4444-bd06-1e65a9ff2bf4", - "material": "cc6498f7-e281-45f2-ab6e-8a333f819761" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -33.49778869290172, - "y": 12.470918740201123, - "z": 8.866807325597065, - "vx": 4.949730945188363e-17, - "vy": 5.24238285561063e-17, - "vz": -4.4791740293906743e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "758d7e77-0cb0-43cf-ac1d-fc0e9a313c0c", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -33.49778869290172, - 12.470918740201123, - 8.866807325597065, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c4e9ef4a-0fd9-4937-a287-248c6a6ea58e", - "material": "2f9b9c9a-b2aa-4c4d-87bf-957300a724be" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 14.890533829357492, - "y": 23.692214096958434, - "z": 23.188142792285632, - "vx": -3.299456513301944e-17, - "vy": -1.0348221199743117e-16, - "vz": 3.052023238520353e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "cefabcbd-54e8-486d-8abc-699589fcd1bc", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 14.890533829357492, - 23.692214096958434, - 23.188142792285632, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "de0e666d-c06d-4381-807a-c4aca085d9c4", - "material": "ba5c3362-d6f2-4dcd-9f03-85202d6ebdfa" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": true, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 13.776862792599529, - "y": -28.38968399768837, - "z": 19.151675996518332, - "vx": -3.2837192535705342e-18, - "vy": 7.284862852281181e-17, - "vz": -5.188539400914919e-17, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "type": "TorusGeometry", - "radius": 5, - "tube": 2, - "radialSegments": 16, - "tubularSegments": 100, - "arc": 6.283185307179586 - } - ], - "materials": [ - { - "uuid": "006c8eb0-38aa-4477-81b1-c667b889e5cc", - "type": "MeshLambertMaterial", - "color": 0, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "e204a9da-3b07-418b-9e3a-7a3e49f71711", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 13.776862792599529, - -28.38968399768837, - 19.151675996518332, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "8a9c2f13-122b-456f-ae68-4a6bbfbd5edb", - "material": "006c8eb0-38aa-4477-81b1-c667b889e5cc" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "Model Poisoning", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "20", - "poisoned_noise_percent": "10", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - } -] diff --git a/nebula/tests/datasets.json b/nebula/tests/datasets.json deleted file mode 100644 index 481843c87..000000000 --- a/nebula/tests/datasets.json +++ /dev/null @@ -1,1327 +0,0 @@ -[ - { - "scenario_title": "FedAvg_Fully_nodes3_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Fully", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 4.101111520322491, - "y": 23.17085759247064, - "z": -28.198675180929786, - "vx": -0.00006545278382253788, - "vy": 0.00005516262023370091, - "vz": 0.00002523863783747619 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -21.682280638382966, - "y": -20.23922441616966, - "z": -20.91596431561703, - "vx": 0.00006631383962439182, - "vy": -0.00008330318827877487, - "vz": 0.000025355038214208375 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -26.826992855206402, - "y": 1.5622417094044505, - "z": 25.12538624177062, - "vx": -0.00001144623471023569, - "vy": 0.00008654210931474939, - "vz": -0.00005076222971649251 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.658293356226036, - "y": 22.733431094533177, - "z": 20.447614013689968, - "vx": 0.00003928657800471614, - "vy": -0.00010439983752290708, - "vz": 0.00000799186931855516 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.749868617040843, - "y": -27.227305980238604, - "z": 3.5416392410862314, - "vx": -0.000028701399096334497, - "vy": 0.000045998296253231754, - "vz": -0.00000782331565374665 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "3", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes3_FashionMNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Fully", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.87214997022893, - "y": 23.411191394915484, - "z": -28.061628860153, - "vx": -0.00026867051404931666, - "vy": 0.00032187283484036276, - "vz": 0.0001150952133122874 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.865540245870243, - "y": -20.912663166632942, - "z": -21.080399625169623, - "vx": 0.00041309402133997506, - "vy": -0.000595404690033262, - "vz": 0.0003391792604412651 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.57079289815702, - "y": 2.663494570544517, - "z": 24.28136089264944, - "vx": 0.00010256781174300331, - "vy": 0.0005842231414888555, - "vz": -0.00038432689674292443 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.1210225697372, - "y": 22.211394039838183, - "z": 20.592017330626152, - "vx": 0.000038416959141038266, - "vy": -0.0008954412595566681, - "vz": 0.00009981972694036827 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.443160604061138, - "y": -27.373416838665243, - "z": 4.268650262047027, - "vx": -0.00028540827817469526, - "vy": 0.0005847499732607106, - "vz": -0.00016976730395100117 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "FashionMNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "3", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes3_CIFAR10_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Fully", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.926053719176481, - "y": 23.341400660315532, - "z": -28.0853206234973, - "vx": -0.0013985912810062686, - "vy": 0.0019363129404175432, - "vz": 0.0006509320293325904 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.958846503238835, - "y": -20.778931847797413, - "z": -21.155485877406612, - "vx": 0.0026604485326592875, - "vy": -0.0037273396178577386, - "vz": 0.0019949246763901972 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.593796969456534, - "y": 2.5325764003068656, - "z": 24.369784156459193, - "vx": 0.0005520100400377787, - "vy": 0.0037086885069337687, - "vz": -0.002558956938463079 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.117278377869173, - "y": 22.41124377515004, - "z": 20.56903062890312, - "vx": 0.00009282581881266439, - "vy": -0.0055527239503684375, - "vz": 0.0006709518004214835 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.509311375649713, - "y": -27.506288987975026, - "z": 4.301991715541601, - "vx": -0.001906693110503491, - "vy": 0.0036350621208748432, - "vz": -0.000757851567681192 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "CIFAR10", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "CNN", - "agg_algorithm": "FedAvg", - "rounds": "3", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Fully_nodes3_MilitarySAR_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Fully", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 3.8619163004079495, - "y": 23.423603059841312, - "z": -28.05755598575629, - "vx": -6.167753851660207e-7, - "vy": 7.205335211080378e-7, - "vz": 2.605174132521207e-7, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "834e81cb-50f8-41f7-9a13-3591c45ac404", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "f128fdec-7ace-45d5-a2ba-fe280f7495f5", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "7c5e8ae3-763c-482d-8fb1-3969c07e2e24", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 3.8619163004079495, - 23.423603059841312, - -28.05755598575629, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "834e81cb-50f8-41f7-9a13-3591c45ac404", - "material": "f128fdec-7ace-45d5-a2ba-fe280f7495f5" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.84928489245508, - "y": -20.93704677581919, - "z": -21.065542349405742, - "vx": 9.130274528925415e-7, - "vy": -0.0000013228658979003826, - "vz": 7.616654398950209e-7, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "4c00a76b-2655-45dc-81e6-5cb761534bda", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "576313c1-13cd-4492-80e7-1bd300d2411c", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "11bfb187-073b-4f18-85ed-f01cb1e2b55d", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -20.84928489245508, - -20.93704677581919, - -21.065542349405742, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "4c00a76b-2655-45dc-81e6-5cb761534bda", - "material": "576313c1-13cd-4492-80e7-1bd300d2411c" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -27.56533994105678, - "y": 2.686470274838121, - "z": 24.266249438133688, - "vx": 2.3368044207797833e-7, - "vy": 0.0000012949833171357604, - "vz": -8.433499499399869e-7, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "29032dd4-a287-4258-93fe-64c6707aa1a6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "2280ab71-99ab-4d1a-95fe-0521328d68a2", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "b648d7d8-dad5-4397-8a63-97ea8fd47d35", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -27.56533994105678, - 2.686470274838121, - 24.266249438133688, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "29032dd4-a287-4258-93fe-64c6707aa1a6", - "material": "2280ab71-99ab-4d1a-95fe-0521328d68a2" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 20.12108428496953, - "y": 22.174981710968808, - "z": 20.5958132761767, - "vx": 9.646561850511574e-8, - "vy": -0.0000019926499339138058, - "vz": 2.179205397364797e-7, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "702f8467-c7dd-4fb6-a259-2200e1532446", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "3578de5c-9fdb-4eca-a23e-fda783497faf", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "ee7524ff-3358-4d4a-8509-793659350bd1", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 20.12108428496953, - 22.174981710968808, - 20.5958132761767, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "702f8467-c7dd-4fb6-a259-2200e1532446", - "material": "3578de5c-9fdb-4eca-a23e-fda783497faf" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 24.43162424813438, - "y": -27.348008269829055, - "z": 4.2610356208516444, - "vx": -6.263981283096149e-7, - "vy": 0.0000012999989935703887, - "vz": -3.9675344294362906e-7, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "2f797c7f-7aeb-4c13-93e0-d34598bf0b9a", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "055c23dd-a857-4693-8969-8bcb298a1e4d", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "29b2b393-5237-4177-8070-2b847d6225cd", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 24.43162424813438, - -27.348008269829055, - 4.2610356208516444, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "2f797c7f-7aeb-4c13-93e0-d34598bf0b9a", - "material": "055c23dd-a857-4693-8969-8bcb298a1e4d" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MilitarySAR", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "Custom", - "agg_algorithm": "FedAvg", - "rounds": "3", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - } -] diff --git a/nebula/tests/main.py b/nebula/tests/main.py deleted file mode 100644 index bab34a069..000000000 --- a/nebula/tests/main.py +++ /dev/null @@ -1,201 +0,0 @@ -import json -import logging -import os -import sys -from datetime import datetime - -import docker - -# Constants -TIMEOUT = 3600 - - -# Detect CTRL+C from parent process -def signal_handler(signal, frame): - logging.info("You pressed Ctrl+C [test]!") - sys.exit(0) - - -# Create nebula netbase if it does not exist -def create_docker_network(): - client = docker.from_env() - - try: - ipam_pool = docker.types.IPAMPool(subnet="192.168.10.0/24", gateway="192.168.10.1") - ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) - - client.networks.create("nebula-net-base", driver="bridge", ipam=ipam_config) - print("Docker network created successfully.") - except docker.errors.APIError as e: - print(f"Error creating Docker network: {e}") - - -# To add a new test create the option in the menu and a [test].json file in tests folder -def menu(): - # clear terminal - if os.name == "nt": - os.system("cls") - else: - os.system("clear") - - banner = """ -β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— -β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ -β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘ -β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ -β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• - A Platform for Decentralized Federated Learning - Created by Enrique TomΓ‘s MartΓ­nez BeltrΓ‘n - https://github.com/CyberDataLab/nebula - """ - print("\x1b[0;36m" + banner + "\x1b[0m") - - options = """ -[1] Aggregation test -[2] Topology test -[3] Dataset test -[4] Attacks test -[5] Custom test -CTRL + C to exit - """ - - while True: - print("\x1b[0;36m" + options + "\x1b[0m") - selectedOption = input("\x1b[0;36m" + "> " + "\x1b[0m") - if selectedOption == "1": - run_test(os.path.join(os.path.dirname(__file__), "aggregation.json")) - elif selectedOption == "2": - run_test(os.path.join(os.path.dirname(__file__), "topology.json")) - elif selectedOption == "3": - run_test(os.path.join(os.path.dirname(__file__), "datasets.json")) - elif selectedOption == "4": - run_test(os.path.join(os.path.dirname(__file__), "attacks.json")) - elif selectedOption == "5": - run_test(os.path.join(os.path.dirname(__file__), "custom.json")) - else: - print("Choose a valid option") - - -# Check for error logs -def check_error_logs(test_name, scenario_name): - try: - log_dir = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "app", "logs")) - current_log = os.path.join(log_dir, scenario_name) - test_dir = os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "app", "tests") - ) - output_log_path = os.path.join(test_dir, test_name + ".log") - - if not os.path.exists(test_dir): - try: - os.mkdir(test_dir) - except Exception as e: - logging.exception(f"Error creating test directory: {e}") - - with open(output_log_path, "a", encoding="utf-8") as f: - f.write(f"Scenario: {scenario_name}\n") - - for log_file in os.listdir(current_log): - if log_file.endswith("_error.log"): - log_file_path = os.path.join(current_log, log_file) - try: - with open(log_file_path, encoding="utf-8") as file: - content = file.read().strip() - if content: - f.write(f"{log_file} ❌ Errors found:\n{content}\n") - else: - f.write(f"{log_file} βœ… No errors found\n") - except Exception as e: - f.write(f"Error reading {log_file}: {e}\n") - - f.write("-" * os.get_terminal_size().columns + "\n") - - except Exception as e: - print(f"Failed to write to log file {test_name + '.log'}: {e}") - - return output_log_path - - -# Load test from .json file -def load_test(test_path): - with open(test_path, encoding="utf-8") as file: - scenarios = json.load(file) - return scenarios - - -# Run selected test -def run_test(test_path): - test_name = f"test_nebula_{os.path.splitext(os.path.basename(test_path))[0]}_" + datetime.now().strftime( - "%d_%m_%Y_%H_%M_%S" - ) - - for scenario in load_test(test_path): - scenarioManagement = run_scenario(scenario) - finished = scenarioManagement.scenario_finished(TIMEOUT) - - if finished: - test_log_path = check_error_logs(test_name, scenarioManagement.scenario_name) - else: - test_dir = os.path.normpath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "..", - "..", - "app", - "tests", - ) - ) - output_log_path = os.path.join(test_dir, test_name + ".log") - - if not os.path.exists(test_dir): - try: - os.mkdir(test_dir) - except Exception as e: - logging.exception(f"Error creating test directory: {e}") - - try: - with open(output_log_path, "a", encoding="utf-8") as f: - f.write(f"Scenario: {scenarioManagement.scenario_name} \n") - f.write("πŸ•’βŒ Timeout reached \n") - f.write("-" * os.get_terminal_size().columns + "\n") - except Exception as e: - print(f"Failed to write to log file {test_name + '.log'}: {e}") - pass - - print("Results:") - try: - with open(test_log_path, encoding="utf-8") as f: - print(f.read()) - except Exception as e: - print(f"Failed to read the log file {test_name + '.log'}: {e}") - - -# Run a single scenario -def run_scenario(scenario): - import subprocess - - from nebula.scenarios import ScenarioManagement - - # Manager for the actual scenario - scenarioManagement = ScenarioManagement(scenario, "nebula-test") - - # Run the actual scenario - try: - if scenarioManagement.scenario.mobility: - additional_participants = scenario["additional_participants"] - schema_additional_participants = scenario["schema_additional_participants"] - scenarioManagement.load_configurations_and_start_nodes( - additional_participants, schema_additional_participants - ) - else: - scenarioManagement.load_configurations_and_start_nodes() - except subprocess.CalledProcessError as e: - logging.exception(f"Error docker-compose up: {e}") - - return scenarioManagement - - -if __name__ == "__main__": - create_docker_network() - menu() diff --git a/nebula/tests/topology.json b/nebula/tests/topology.json deleted file mode 100644 index 885bab096..000000000 --- a/nebula/tests/topology.json +++ /dev/null @@ -1,1065 +0,0 @@ -[ - { - "scenario_title": "FedAvg_Fully_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Fully", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": 4.993184195908952, - "y": 22.97379020384758, - "z": -28.25515198841153, - "vx": -0.000008284590984646814, - "vy": 0.000012676942801671354, - "vz": 0.00000337444224691038 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2, - 3, - 4 - ], - "links": [], - "index": 1, - "x": -20.34350561420848, - "y": -21.09966802297166, - "z": -21.394730709001507, - "vx": 0.00001529548690662486, - "vy": -0.00002502816057511246, - "vz": 0.000017735151914665576 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 3, - 4 - ], - "links": [], - "index": 2, - "x": -28.363830762563218, - "y": 3.61755783716743, - "z": 23.212183052878427, - "vx": 0.000008163722298512913, - "vy": 0.000023504685792941492, - "vz": -0.00001610364102037641 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 4 - ], - "links": [], - "index": 3, - "x": 19.86654942045622, - "y": 22.13016749905516, - "z": 20.889479953571218, - "vx": -0.0000027398628529030482, - "vy": -0.000039969648854093444, - "vz": 0.000005300555612345822 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 1, - 2, - 3 - ], - "links": [], - "index": 4, - "x": 23.847602760406524, - "y": -27.621847517098512, - "z": 5.5482196909634, - "vx": -0.00001243475536758801, - "vy": 0.000028816180834592956, - "vz": -0.000010306508753545306 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 1, - 1, - 1 - ], - [ - 1, - 1, - 0, - 1, - 1 - ], - [ - 1, - 1, - 1, - 0, - 1 - ], - [ - 1, - 1, - 1, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Ring_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Ring", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 4 - ], - "links": [], - "index": 0, - "x": -0.48387496165889815, - "y": -14.903262492881392, - "z": -46.68781626505614, - "vx": -0.007549366104375752, - "vy": -0.0029866880746469795, - "vz": -0.009302910010080587 - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 2 - ], - "links": [], - "index": 1, - "x": -46.846944738649746, - "y": -4.390679247689683, - "z": -13.419119996429474, - "vx": -0.0074402448841658, - "vy": -0.003033574168515333, - "vz": -0.009450586437421983 - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 1, - 3 - ], - "links": [], - "index": 2, - "x": -28.723241245070824, - "y": 12.171638656165907, - "z": 38.33966797566664, - "vx": -0.007559444764695181, - "vy": -0.0029132198857957006, - "vz": -0.009072664832194963 - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 2, - 4 - ], - "links": [], - "index": 3, - "x": 29.44433157921186, - "y": 11.983508186466985, - "z": 37.3322075340449, - "vx": -0.007135790774730776, - "vy": -0.002837546122705624, - "vz": -0.008838626959382492 - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0, - 3 - ], - "links": [], - "index": 4, - "x": 46.57326778138079, - "y": -4.876075931956438, - "z": -15.611266879474085, - "vx": -0.00677673825883925, - "vy": -0.0030998016429569456, - "vz": -0.00966284300908014 - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 0, - 0, - 1 - ], - [ - 1, - 0, - 1, - 0, - 0 - ], - [ - 0, - 1, - 0, - 1, - 0 - ], - [ - 0, - 0, - 1, - 0, - 1 - ], - [ - 1, - 0, - 0, - 1, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - }, - { - "scenario_title": "FedAvg_Star_nodes5_MNIST_No Attack", - "scenario_description": "", - "simulation": true, - "federation": "DFL", - "topology": "Star", - "nodes": { - "0": { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true - }, - "1": { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "2": { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "3": { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - }, - "4": { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false - } - }, - "nodes_graph": [ - { - "id": 0, - "ip": "192.168.50.2", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": true, - "neighbors": [ - 1, - 2, - 3, - 4 - ], - "links": [], - "index": 0, - "x": -0.3347515129540105, - "y": -0.5153982444098931, - "z": -1.5448148516371116, - "vx": -8.221077433670136e-15, - "vy": -1.0658781295994897e-14, - "vz": -3.515875577631303e-14, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "939c9041-5267-4b2e-9eb1-c28034152034", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "d07068dc-a138-4ab9-90a3-028d1672753f", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "4630bf91-ecbc-48d8-a577-85fa2876a036", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -0.3347515129540105, - -0.5153982444098931, - -1.5448148516371116, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "939c9041-5267-4b2e-9eb1-c28034152034", - "material": "d07068dc-a138-4ab9-90a3-028d1672753f" - } - } - }, - { - "id": 1, - "ip": "192.168.50.3", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0 - ], - "links": [], - "index": 1, - "x": -18.815782081483192, - "y": -4.195150249253213, - "z": -51.56070280147786, - "vx": 1.0072851776561277e-14, - "vy": 7.789537440275329e-15, - "vz": -4.391896659941205e-14, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "260ef0df-fa6c-4b94-a6be-9938c9a03ec9", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "be1c77ae-674c-4554-915e-bd9326a75205", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "400e50b9-8766-41f8-b925-44bc46042867", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -18.815782081483192, - -4.195150249253213, - -51.56070280147786, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "260ef0df-fa6c-4b94-a6be-9938c9a03ec9", - "material": "be1c77ae-674c-4554-915e-bd9326a75205" - } - } - }, - { - "id": 2, - "ip": "192.168.50.4", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0 - ], - "links": [], - "index": 2, - "x": -41.963556920501176, - "y": -1.5204892912193742, - "z": 32.085148318373996, - "vx": -1.8459831846723455e-14, - "vy": -5.842294158739215e-14, - "vz": -4.608716832288045e-14, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "c1c09fbb-82c7-431d-a75d-347291fb8fa6", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "38ffd148-920c-49a7-be6c-eed063d550c5", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "4332a937-fea3-4b0d-b02a-4242cb0a0a2a", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - -41.963556920501176, - -1.5204892912193742, - 32.085148318373996, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "c1c09fbb-82c7-431d-a75d-347291fb8fa6", - "material": "38ffd148-920c-49a7-be6c-eed063d550c5" - } - } - }, - { - "id": 3, - "ip": "192.168.50.5", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0 - ], - "links": [], - "index": 3, - "x": 24.682265840773542, - "y": 44.460587489186466, - "z": 13.183794706459942, - "vx": -7.902320671548714e-15, - "vy": 1.6622645087265178e-14, - "vz": -1.0159426291566943e-13, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "56120343-60ae-4d59-a645-1a894a6a0df5", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "81b759bd-9ff3-4298-8f0c-882216c9df31", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "68ebeda7-c54b-4874-920f-063a7edb7ff1", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 24.682265840773542, - 44.460587489186466, - 13.183794706459942, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "56120343-60ae-4d59-a645-1a894a6a0df5", - "material": "81b759bd-9ff3-4298-8f0c-882216c9df31" - } - } - }, - { - "id": 4, - "ip": "192.168.50.6", - "port": "45000", - "role": "aggregator", - "malicious": false, - "proxy": false, - "start": false, - "neighbors": [ - 0 - ], - "links": [], - "index": 4, - "x": 36.431824674164766, - "y": -38.22954970430408, - "z": 7.836574628280765, - "vx": -3.342435331237832e-14, - "vy": -4.942866863128787e-14, - "vz": -4.741260763908042e-14, - "__threeObj": { - "metadata": { - "version": 4.6, - "type": "Object", - "generator": "Object3D.toJSON" - }, - "geometries": [ - { - "uuid": "da3f0526-4a1a-43f2-ad02-c683ebef2687", - "type": "SphereGeometry", - "radius": 5, - "widthSegments": 32, - "heightSegments": 16, - "phiStart": 0, - "phiLength": 6.283185307179586, - "thetaStart": 0, - "thetaLength": 3.141592653589793 - } - ], - "materials": [ - { - "uuid": "daf46a69-3be4-4cf0-bb03-bc04c817ca10", - "type": "MeshLambertMaterial", - "color": 14245634, - "emissive": 0, - "reflectivity": 1, - "refractionRatio": 0.98, - "opacity": 0.75, - "depthFunc": 3, - "depthTest": true, - "depthWrite": true, - "colorWrite": true, - "stencilWrite": false, - "stencilWriteMask": 255, - "stencilFunc": 519, - "stencilRef": 0, - "stencilFuncMask": 255, - "stencilFail": 7680, - "stencilZFail": 7680, - "stencilZPass": 7680 - } - ], - "object": { - "uuid": "efbac863-dac2-481e-be9e-6142d96967b1", - "type": "Mesh", - "layers": 1, - "matrix": [ - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 36.431824674164766, - -38.22954970430408, - 7.836574628280765, - 1 - ], - "up": [ - 0, - 1, - 0 - ], - "geometry": "da3f0526-4a1a-43f2-ad02-c683ebef2687", - "material": "daf46a69-3be4-4cf0-bb03-bc04c817ca10" - } - } - } - ], - "n_nodes": 5, - "matrix": [ - [ - 0, - 1, - 1, - 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0 - ] - ], - "dataset": "MNIST", - "iid": false, - "partition_selection": "dirichlet", - "partition_parameter": "0.5", - "model": "MLP", - "agg_algorithm": "FedAvg", - "rounds": "10", - "logginglevel": true, - "accelerator": "gpu", - "network_subnet": "192.168.50.0/24", - "network_gateway": "192.168.50.1", - "epochs": "1", - "attacks": "No Attack", - "poisoned_node_percent": "0", - "poisoned_sample_percent": "0", - "poisoned_noise_percent": "0", - "with_reputation": false, - "is_dynamic_topology": false, - "is_dynamic_aggregation": false, - "target_aggregation": false, - "random_geo": true, - "latitude": 38.023522, - "longitude": -1.174389, - "mobility": false, - "mobility_type": "both", - "radius_federation": "1000", - "scheme_mobility": "random", - "round_frequency": "1", - "mobile_participants_percent": "100", - "additional_participants": [], - "schema_additional_participants": "random" - } -] diff --git a/nebula/utils.py b/nebula/utils.py index 3b6962f48..5ee82115a 100644 --- a/nebula/utils.py +++ b/nebula/utils.py @@ -9,6 +9,7 @@ class FileUtils: """ Utility class for file operations. """ + @classmethod def check_path(cls, base_path, relative_path): full_path = os.path.normpath(os.path.join(base_path, relative_path)) @@ -23,6 +24,7 @@ class SocketUtils: """ Utility class for socket operations. """ + @classmethod def is_port_open(cls, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -45,6 +47,7 @@ class DockerUtils: """ Utility class for Docker operations. """ + @classmethod def create_docker_network(cls, network_name, subnet=None, prefix=24): try: diff --git a/pyproject.toml b/pyproject.toml index fea1cba17..cd264a03b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ docs = [ "mkdocstrings[python]<1.0.0,>=0.26.2", ] controller = [ + "aiosqlite==0.20.0", + "argon2-cffi==23.1.0", "ansi2html==1.9.2", "docker==7.1.0", "fastapi[all]==0.114.0", @@ -123,9 +125,7 @@ core = [ "h5py==3.13.0", ] frontend = [ - "aiosqlite==0.20.0", "ansi2html==1.9.2", - "argon2-cffi==23.1.0", "cffi==1.17.1", "cryptography==43.0.1", "docker==7.1.0",