diff --git a/framework/py/flwr/server/app.py b/framework/py/flwr/server/app.py index bb0af9380a18..b598ae574b01 100644 --- a/framework/py/flwr/server/app.py +++ b/framework/py/flwr/server/app.py @@ -60,7 +60,6 @@ from flwr.server.fleet_event_log_interceptor import FleetEventLogInterceptor from flwr.supercore.address import parse_address, resolve_bind_address from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME -from flwr.supercore.ffs import FfsFactory from flwr.supercore.grpc_health import add_args_health, run_health_server_grpc_no_tls from flwr.supercore.object_store import ObjectStoreFactory from flwr.supercore.update_check import warn_if_flwr_update_available @@ -307,15 +306,18 @@ def run_superlink() -> None: ) state_factory.state() # Force initialization before starting servers - # Initialize FfsFactory - ffs_factory = FfsFactory(args.storage_dir) + if "--storage-dir" in explicit_args: + log( + WARN, + "The `--storage-dir` argument is deprecated and has no effect in " + "SuperLink. FAB artifacts are stored in LinkState.", + ) # Start Control API is_simulation = args.simulation control_server: grpc.Server = run_control_api_grpc( address=control_address, state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, certificates=certificates, authn_plugin=authn_plugin, @@ -331,7 +333,6 @@ def run_superlink() -> None: serverappio_server: grpc.Server = run_serverappio_api_grpc( address=serverappio_address, state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, certificates=None, # ServerAppIo API doesn't support SSL yet ) @@ -378,7 +379,6 @@ def run_superlink() -> None: args.ssl_keyfile, args.ssl_certfile, state_factory, - ffs_factory, objectstore_factory, num_workers, ), @@ -398,7 +398,6 @@ def run_superlink() -> None: fleet_server = _run_fleet_api_grpc_rere( address=fleet_address, state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, enable_supernode_auth=enable_supernode_auth, certificates=certificates, @@ -409,7 +408,6 @@ def run_superlink() -> None: fleet_server = _run_fleet_api_grpc_adapter( address=fleet_address, state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, certificates=certificates, ) @@ -553,7 +551,6 @@ def _try_obtain_fleet_event_log_writer_plugin() -> EventLogWriterPlugin | None: def _run_fleet_api_grpc_rere( # pylint: disable=R0913, R0917 address: str, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, enable_supernode_auth: bool, certificates: tuple[bytes, bytes, bytes] | None, @@ -563,7 +560,6 @@ def _run_fleet_api_grpc_rere( # pylint: disable=R0913, R0917 # Create Fleet API gRPC server fleet_servicer = FleetServicer( state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, enable_supernode_auth=enable_supernode_auth, ) @@ -590,7 +586,6 @@ def _run_fleet_api_grpc_rere( # pylint: disable=R0913, R0917 def _run_fleet_api_grpc_adapter( address: str, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, certificates: tuple[bytes, bytes, bytes] | None, ) -> grpc.Server: @@ -598,7 +593,6 @@ def _run_fleet_api_grpc_adapter( # Create Fleet API gRPC server fleet_servicer = GrpcAdapterServicer( state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, enable_supernode_auth=False, ) @@ -628,7 +622,6 @@ def _run_fleet_api_rest( ssl_keyfile: str | None, ssl_certfile: str | None, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, num_workers: int, ) -> None: @@ -644,7 +637,6 @@ def _run_fleet_api_rest( # See: https://www.starlette.io/applications/#accessing-the-app-instance fast_api_app.state.STATE_FACTORY = state_factory - fast_api_app.state.FFS_FACTORY = ffs_factory fast_api_app.state.OBJECTSTORE_FACTORY = objectstore_factory uvicorn.run( @@ -731,7 +723,7 @@ def _add_args_common(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--storage-dir", - help="The base directory to store the objects for the Flower File System.", + help="Deprecated and ignored by SuperLink.", default=BASE_DIR, ) parser.add_argument( diff --git a/framework/py/flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer_test.py b/framework/py/flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer_test.py index 23d0ba85d5fa..367a03384054 100644 --- a/framework/py/flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer_test.py +++ b/framework/py/flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer_test.py @@ -26,7 +26,7 @@ def test_rpc_completion() -> None: """Test if the GrpcAdapter servicer can handle all requests for Fleet API.""" # Prepare all_method_names = (name for name in dir(FleetServicer) if name[0].isupper()) - servicer = GrpcAdapterServicer(Mock(), Mock(), Mock(), Mock()) + servicer = GrpcAdapterServicer(Mock(), Mock(), False) # Execute for method_name in all_method_names: diff --git a/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py b/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py index 177ace53d5f5..529f627afc26 100644 --- a/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +++ b/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py @@ -56,7 +56,6 @@ from flwr.server.superlink.fleet.message_handler import message_handler from flwr.server.superlink.linkstate import LinkStateFactory from flwr.server.superlink.utils import abort_grpc_context -from flwr.supercore.ffs import FfsFactory from flwr.supercore.inflatable.inflatable_object import UnexpectedObjectContentError from flwr.supercore.object_store import ObjectStoreFactory @@ -67,12 +66,10 @@ class FleetServicer(fleet_pb2_grpc.FleetServicer): def __init__( self, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, enable_supernode_auth: bool, ) -> None: self.state_factory = state_factory - self.ffs_factory = ffs_factory self.objectstore_factory = objectstore_factory self.enable_supernode_auth = enable_supernode_auth self.lock = threading.Lock() @@ -262,7 +259,6 @@ def GetFab( try: res = message_handler.get_fab( request=request, - ffs=self.ffs_factory.ffs(), state=self.state_factory.state(), store=self.objectstore_factory.store(), ) diff --git a/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer_test.py b/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer_test.py index a0e0df5ef938..846573d1fd22 100644 --- a/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer_test.py +++ b/framework/py/flwr/server/superlink/fleet/grpc_rere/fleet_servicer_test.py @@ -15,7 +15,6 @@ """Flower FleetServicer tests.""" -import tempfile import unittest from unittest.mock import Mock, patch @@ -72,7 +71,6 @@ NodeStatus, RunType, ) -from flwr.supercore.ffs import FfsFactory from flwr.supercore.inflatable.inflatable_object import ( get_all_nested_objects, get_object_id, @@ -90,17 +88,11 @@ class TestFleetServicer(unittest.TestCase): # pylint: disable=R0902, R0904 def setUp(self) -> None: """Initialize mock stub and server interceptor.""" - # Create a temporary directory - self.temp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 - self.addCleanup(self.temp_dir.cleanup) # Ensures cleanup after test - objectstore_factory = ObjectStoreFactory() state_factory = LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), objectstore_factory ) self.state = state_factory.state() - ffs_factory = FfsFactory(self.temp_dir.name) - self.ffs = ffs_factory.ffs() self.store = objectstore_factory.store() self.node_pk = b"fake public key" @@ -109,7 +101,6 @@ def setUp(self) -> None: self._server: grpc.Server = _run_fleet_api_grpc_rere( FLEET_API_GRPC_RERE_DEFAULT_ADDRESS, state_factory, - ffs_factory, objectstore_factory, self.enable_node_auth, None, @@ -175,7 +166,7 @@ def setUp(self) -> None: def tearDown(self) -> None: """Clean up grpc server.""" - self._server.stop(None) + self._server.stop(None).wait(timeout=2) def _create_dummy_node(self, activate: bool = True) -> int: """Create a dummy node.""" @@ -509,7 +500,7 @@ def test_successful_get_fab_if_running(self) -> None: # Prepare node_id = self._create_dummy_node() fab_content = b"content" - fab_hash = self.ffs.put(fab_content, {"meta": "data"}) + fab_hash = self.state.put_fab(fab_content, {"meta": "data"}) run_id = self._create_dummy_run(fab_hash=fab_hash) # Transition status to running. GetFab RPC is only allowed in running status. @@ -550,7 +541,7 @@ def test_get_fab_not_successful_if_not_running(self, num_transitions: int) -> No # Prepare node_id = self._create_dummy_node() fab_content = b"content" - fab_hash = self.ffs.put(fab_content, {"meta": "data"}) + fab_hash = self.state.put_fab(fab_content, {"meta": "data"}) run_id = self._create_dummy_run(running=False, fab_hash=fab_hash) self._transition_run_status(run_id, num_transitions) @@ -563,7 +554,7 @@ def test_get_fab_permission_denied_if_node_not_in_federation(self) -> None: # Prepare node_id = self._create_dummy_node() fab_content = b"content" - fab_hash = self.ffs.put(fab_content, {"meta": "data"}) + fab_hash = self.state.put_fab(fab_content, {"meta": "data"}) run_id = self._create_dummy_run(fab_hash=fab_hash) # Mock federation manager to exclude the node diff --git a/framework/py/flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor_test.py b/framework/py/flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor_test.py index 155c837b8b71..c032ee95428e 100644 --- a/framework/py/flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor_test.py +++ b/framework/py/flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor_test.py @@ -16,7 +16,6 @@ import datetime -import tempfile import unittest from collections.abc import Callable from typing import Any @@ -70,7 +69,6 @@ from flwr.server.superlink.linkstate.linkstate_factory import LinkStateFactory from flwr.server.superlink.linkstate.linkstate_test import create_res_message from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME, NOOP_FEDERATION, RunType -from flwr.supercore.ffs import FfsFactory from flwr.supercore.object_store import ObjectStoreFactory from flwr.supercore.primitives.asymmetric import ( generate_key_pairs, @@ -97,16 +95,12 @@ def setUp(self) -> None: FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), objectstore_factory ) self.state = state_factory.state() - self.tmp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 - ffs_factory = FfsFactory(self.tmp_dir.name) - self.ffs = ffs_factory.ffs() self.store = objectstore_factory.store() self._server_interceptor = NodeAuthServerInterceptor(state_factory) self._server: grpc.Server = _run_fleet_api_grpc_rere( FLEET_API_GRPC_RERE_DEFAULT_ADDRESS, state_factory, - ffs_factory, objectstore_factory, self.enable_node_auth, None, @@ -172,9 +166,7 @@ def setUp(self) -> None: def tearDown(self) -> None: """Clean up grpc server.""" - self._server.stop(None) - # Cleanup the temp directory - self.tmp_dir.cleanup() + self._server.stop(None).wait(timeout=2) def _make_metadata(self) -> list[Any]: """Create metadata with signature and timestamp.""" @@ -316,7 +308,7 @@ def _test_send_node_heartbeat(self, metadata: list[Any]) -> Any: def _test_get_fab(self, metadata: list[Any]) -> Any: """Test GetFab.""" - fab_hash = self.ffs.put(b"mock fab content", {}) + fab_hash = self.state.put_fab(b"mock fab content", {}) node_id = self._create_node_in_linkstate() run_id = self._create_dummy_run() req = GetFabRequest( diff --git a/framework/py/flwr/server/superlink/fleet/message_handler/message_handler.py b/framework/py/flwr/server/superlink/fleet/message_handler/message_handler.py index 84cbcbfe9d62..22276a5e4e70 100644 --- a/framework/py/flwr/server/superlink/fleet/message_handler/message_handler.py +++ b/framework/py/flwr/server/superlink/fleet/message_handler/message_handler.py @@ -62,7 +62,6 @@ from flwr.proto.run_pb2 import GetRunRequest, GetRunResponse # pylint: disable=E0611 from flwr.server.superlink.linkstate import LinkState from flwr.server.superlink.utils import check_abort -from flwr.supercore.ffs import Ffs from flwr.supercore.inflatable.inflatable_object import UnexpectedObjectContentError from flwr.supercore.object_store import NoObjectInStoreError, ObjectStore @@ -250,7 +249,7 @@ def get_run( def get_fab( - request: GetFabRequest, ffs: Ffs, state: LinkState, store: ObjectStore + request: GetFabRequest, state: LinkState, store: ObjectStore ) -> GetFabResponse: """Get FAB.""" # Validate that the requesting SuperNode is part of the federation @@ -266,7 +265,7 @@ def get_fab( if abort_msg: raise InvalidRunStatusException(abort_msg) - if result := ffs.get(request.hash_str): + if result := state.get_fab(request.hash_str): fab = Fab(request.hash_str, result[0], result[1]) return GetFabResponse(fab=fab_to_proto(fab)) diff --git a/framework/py/flwr/server/superlink/fleet/rest_rere/rest_api.py b/framework/py/flwr/server/superlink/fleet/rest_rere/rest_api.py index 77303e16c8cf..9692d698449d 100644 --- a/framework/py/flwr/server/superlink/fleet/rest_rere/rest_api.py +++ b/framework/py/flwr/server/superlink/fleet/rest_rere/rest_api.py @@ -53,7 +53,6 @@ from flwr.proto.run_pb2 import GetRunRequest, GetRunResponse # pylint: disable=E0611 from flwr.server.superlink.fleet.message_handler import message_handler from flwr.server.superlink.linkstate import LinkState, LinkStateFactory -from flwr.supercore.ffs import Ffs, FfsFactory from flwr.supercore.object_store import ObjectStore, ObjectStoreFactory try: @@ -224,15 +223,12 @@ async def get_run(request: GetRunRequest) -> GetRunResponse: @rest_request_response(GetFabRequest) async def get_fab(request: GetFabRequest) -> GetFabResponse: """GetRun.""" - # Get ffs from app - ffs: Ffs = cast(FfsFactory, app.state.FFS_FACTORY).ffs() - # Get state from app state: LinkState = cast(LinkStateFactory, app.state.STATE_FACTORY).state() store: ObjectStore = cast(ObjectStoreFactory, app.state.OBJECTSTORE_FACTORY).store() # Handle message - return message_handler.get_fab(request=request, ffs=ffs, state=state, store=store) + return message_handler.get_fab(request=request, state=state, store=store) @rest_request_response(ConfirmMessageReceivedRequest) diff --git a/framework/py/flwr/server/superlink/linkstate/in_memory_linkstate.py b/framework/py/flwr/server/superlink/linkstate/in_memory_linkstate.py index 49d61bbaae09..30163b77e339 100644 --- a/framework/py/flwr/server/superlink/linkstate/in_memory_linkstate.py +++ b/framework/py/flwr/server/superlink/linkstate/in_memory_linkstate.py @@ -15,6 +15,7 @@ """In-memory LinkState implementation.""" +import hashlib import threading from bisect import bisect_right from collections import defaultdict @@ -91,6 +92,7 @@ def __init__( self.flwr_aid_to_run_ids: dict[str, set[int]] = defaultdict(set) self.node_public_keys: set[bytes] = set() + self.fab_artifacts: dict[str, tuple[bytes, dict[str, str]]] = {} self.lock = threading.RLock() federation_manager.linkstate = self @@ -101,6 +103,34 @@ def federation_manager(self) -> FederationManager: """Get the FederationManager instance.""" return self._federation_manager + def put_fab(self, content: bytes, verifications: dict[str, str]) -> str: + """Store FAB content and verifications and return FAB hash.""" + fab_hash = hashlib.sha256(content).hexdigest() + with self.lock: + self.fab_artifacts.setdefault( + fab_hash, + (content, dict(verifications)), + ) + return fab_hash + + def get_fab(self, fab_hash: str) -> tuple[bytes, dict[str, str]] | None: + """Retrieve FAB content and verifications by hash.""" + with self.lock: + entry = self.fab_artifacts.get(fab_hash) + if entry is None: + return None + content, verifications = entry + if hashlib.sha256(content).hexdigest() != fab_hash: + log(ERROR, "Corrupt FAB artifact in LinkState for hash %s", fab_hash) + return None + if not all( + isinstance(key, str) and isinstance(value, str) + for key, value in verifications.items() + ): + log(ERROR, "Invalid FAB verification metadata for hash %s", fab_hash) + return None + return content, dict(verifications) + def store_message_ins(self, message: Message) -> str | None: """Store one Message.""" # Validate message diff --git a/framework/py/flwr/server/superlink/linkstate/linkstate.py b/framework/py/flwr/server/superlink/linkstate/linkstate.py index 721d90ae355c..b67a1026f3d0 100644 --- a/framework/py/flwr/server/superlink/linkstate/linkstate.py +++ b/framework/py/flwr/server/superlink/linkstate/linkstate.py @@ -36,6 +36,38 @@ class LinkState(CoreState): # pylint: disable=R0904 def federation_manager(self) -> FederationManager: """Return the FederationManager instance.""" + @abc.abstractmethod + def put_fab(self, content: bytes, verifications: dict[str, str]) -> str: + """Store FAB content and verifications and return FAB hash. + + Parameters + ---------- + content : bytes + FAB payload bytes. + verifications : dict[str, str] + Verification metadata to associate with the FAB payload. + + Returns + ------- + str + SHA256 hex hash of `content`. + """ + + @abc.abstractmethod + def get_fab(self, fab_hash: str) -> tuple[bytes, dict[str, str]] | None: + """Retrieve FAB content and verifications by hash. + + Parameters + ---------- + fab_hash : str + SHA256 hash string of FAB content. + + Returns + ------- + tuple[bytes, dict[str, str]] | None + Stored FAB content and verification metadata, if present. + """ + @abc.abstractmethod def store_message_ins(self, message: Message) -> str | None: """Store one Message. diff --git a/framework/py/flwr/server/superlink/linkstate/linkstate_test.py b/framework/py/flwr/server/superlink/linkstate/linkstate_test.py index 42d6911d7581..ce5522fe217e 100644 --- a/framework/py/flwr/server/superlink/linkstate/linkstate_test.py +++ b/framework/py/flwr/server/superlink/linkstate/linkstate_test.py @@ -16,12 +16,14 @@ # pylint: disable=invalid-name, too-many-lines, R0904, R0913 +import hashlib import secrets import tempfile import time import unittest from abc import abstractmethod from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import Mock, patch from uuid import uuid4 @@ -54,7 +56,12 @@ from flwr.proto.recorddict_pb2 import RecordDict as ProtoRecordDict # pylint: enable=E0611 -from flwr.server.superlink.linkstate import InMemoryLinkState, LinkState, SqlLinkState +from flwr.server.superlink.linkstate import ( + InMemoryLinkState, + LinkState, + LinkStateFactory, + SqlLinkState, +) from flwr.supercore.constant import NOOP_FEDERATION, NodeStatus, RunType from flwr.supercore.corestate.corestate_test import StateTest as CoreStateTest from flwr.supercore.object_store.object_store_factory import ObjectStoreFactory @@ -78,6 +85,94 @@ def create_public_key(self) -> bytes: _, public_key = generate_key_pairs() return public_key_to_bytes(public_key) + def test_put_and_get_fab(self) -> None: + """Test storing and retrieving FAB content.""" + state = self.state_factory() + content = b"fab-content" + verifications = {"publisher": "flwr"} + expected_hash = hashlib.sha256(content).hexdigest() + + fab_hash = state.put_fab(content, verifications) + result = state.get_fab(fab_hash) + + self.assertEqual(fab_hash, expected_hash) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result[0], content) + self.assertEqual(result[1], verifications) + + def test_put_fab_is_idempotent(self) -> None: + """Test repeated puts of the same FAB content are idempotent.""" + state = self.state_factory() + content = b"fab-content" + verifications = {"publisher": "flwr"} + + fab_hash_1 = state.put_fab(content, verifications) + fab_hash_2 = state.put_fab(content, verifications) + + self.assertEqual(fab_hash_1, fab_hash_2) + result = state.get_fab(fab_hash_1) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result[0], content) + self.assertEqual(result[1], verifications) + + if isinstance(state, InMemoryLinkState): + self.assertEqual(len(state.fab_artifacts), 1) + elif isinstance(state, SqlLinkState): + rows = state.query( + "SELECT COUNT(*) AS count FROM fab_artifact WHERE fab_hash = :fab_hash", + {"fab_hash": fab_hash_1}, + ) + self.assertEqual(rows[0]["count"], 1) + + def test_get_fab_missing_returns_none(self) -> None: + """Test missing FAB returns None.""" + state = self.state_factory() + self.assertIsNone(state.get_fab("does-not-exist")) + + def test_get_fab_returns_none_for_corrupt_content(self) -> None: + """Test corrupt FAB payload fails integrity validation.""" + state = self.state_factory() + fab_hash = state.put_fab(b"fab-content", {"publisher": "flwr"}) + + if isinstance(state, InMemoryLinkState): + with state.lock: + _, verifications = state.fab_artifacts[fab_hash] + state.fab_artifacts[fab_hash] = (b"tampered-content", verifications) + elif isinstance(state, SqlLinkState): + state.query( + "UPDATE fab_artifact SET content = :content WHERE fab_hash = :fab_hash", + {"content": b"tampered-content", "fab_hash": fab_hash}, + ) + + self.assertIsNone(state.get_fab(fab_hash)) + + def test_get_fab_returns_none_for_corrupt_verifications(self) -> None: + """Test corrupt FAB verification metadata fails validation.""" + state = self.state_factory() + fab_hash = state.put_fab(b"fab-content", {"publisher": "flwr"}) + + if isinstance(state, InMemoryLinkState): + with state.lock: + content, _ = state.fab_artifacts[fab_hash] + # Intentional runtime-invalid metadata shape. + state.fab_artifacts[fab_hash] = ( + content, + cast(dict[str, str], {"publisher": 123}), + ) + elif isinstance(state, SqlLinkState): + state.query( + """ + UPDATE fab_artifact + SET verifications = :verifications + WHERE fab_hash = :fab_hash + """, + {"verifications": '{"publisher": 123}', "fab_hash": fab_hash}, + ) + + self.assertIsNone(state.get_fab(fab_hash)) + def test_create_and_get_run_info(self) -> None: """Test if create_run and get_run_info work correctly.""" # Prepare @@ -1945,6 +2040,49 @@ def state_factory(self) -> SqlLinkState: state.initialize() return state + def test_fab_retrievable_across_state_factories(self) -> None: + """Test FAB persisted by one SQL state is readable by another.""" + with tempfile.TemporaryDirectory() as tmp_dir: + database_path = f"{tmp_dir}/state.db" + objectstore_factory = ObjectStoreFactory(database_path) + + state_factory_a = LinkStateFactory( + database_path, + NoOpFederationManager(), + objectstore_factory, + ) + state_a = state_factory_a.state() + + content = b"fab-content" + verifications = {"publisher": "flwr"} + fab_hash = state_a.put_fab(content, verifications) + run_id = state_a.create_run( + "flwr/demo", + "v1.0.0", + fab_hash, + {}, + NOOP_FEDERATION, + ConfigRecord(), + "test-aid", + RunType.SERVER_APP, + ) + + state_factory_b = LinkStateFactory( + database_path, + NoOpFederationManager(), + objectstore_factory, + ) + state_b = state_factory_b.state() + runs = state_b.get_run_info(run_ids=[run_id]) + + self.assertTrue(runs) + self.assertEqual(runs[0].fab_hash, fab_hash) + result = state_b.get_fab(fab_hash) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result[0], content) + self.assertEqual(result[1], verifications) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/framework/py/flwr/server/superlink/linkstate/sql_linkstate.py b/framework/py/flwr/server/superlink/linkstate/sql_linkstate.py index 8cd9f6797eca..a7b1c9941971 100644 --- a/framework/py/flwr/server/superlink/linkstate/sql_linkstate.py +++ b/framework/py/flwr/server/superlink/linkstate/sql_linkstate.py @@ -17,6 +17,7 @@ # pylint: disable=too-many-lines +import hashlib import json from collections.abc import Sequence from datetime import datetime, timezone @@ -99,6 +100,67 @@ def federation_manager(self) -> FederationManager: """Return the FederationManager instance.""" return self._federation_manager + def put_fab(self, content: bytes, verifications: dict[str, str]) -> str: + """Store FAB content and verifications and return FAB hash.""" + fab_hash = hashlib.sha256(content).hexdigest() + + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in verifications.items() + ): + raise ValueError("`verifications` must be a dict[str, str]") + + with self.session(): + self.query( + """ + INSERT INTO fab_artifact (fab_hash, content, verifications, created_at) + VALUES (:fab_hash, :content, :verifications, :created_at) + ON CONFLICT(fab_hash) DO NOTHING + """, + { + "fab_hash": fab_hash, + "content": content, + "verifications": json.dumps(verifications), + "created_at": now().isoformat(), + }, + ) + + return fab_hash + + def get_fab(self, fab_hash: str) -> tuple[bytes, dict[str, str]] | None: + """Retrieve FAB content and verifications by hash.""" + rows = self.query( + """ + SELECT content, verifications + FROM fab_artifact + WHERE fab_hash = :fab_hash + """, + {"fab_hash": fab_hash}, + ) + if not rows: + return None + + content = bytes(rows[0]["content"]) + if hashlib.sha256(content).hexdigest() != fab_hash: + log(ERROR, "Corrupt FAB artifact in LinkState for hash %s", fab_hash) + return None + + verifications_raw = rows[0]["verifications"] + try: + verifications = json.loads(verifications_raw) + except json.JSONDecodeError: + log(ERROR, "Invalid FAB verification metadata for hash %s", fab_hash) + return None + + if not isinstance(verifications, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in verifications.items() + ): + log(ERROR, "Invalid FAB verification metadata for hash %s", fab_hash) + return None + + return content, verifications + def store_message_ins(self, message: Message) -> str | None: """Store one Message.""" # Validate message diff --git a/framework/py/flwr/server/superlink/serverappio/serverappio_grpc.py b/framework/py/flwr/server/superlink/serverappio/serverappio_grpc.py index 33aea1cceba6..6658077c3648 100644 --- a/framework/py/flwr/server/superlink/serverappio/serverappio_grpc.py +++ b/framework/py/flwr/server/superlink/serverappio/serverappio_grpc.py @@ -26,7 +26,6 @@ add_ServerAppIoServicer_to_server, ) from flwr.server.superlink.linkstate import LinkStateFactory -from flwr.supercore.ffs import FfsFactory from flwr.supercore.object_store import ObjectStoreFactory from .serverappio_servicer import ServerAppIoServicer @@ -35,7 +34,6 @@ def run_serverappio_api_grpc( address: str, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, certificates: tuple[bytes, bytes, bytes] | None, ) -> grpc.Server: @@ -43,7 +41,6 @@ def run_serverappio_api_grpc( # Create ServerAppIo API gRPC server serverappio_servicer: grpc.Server = ServerAppIoServicer( state_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, ) serverappio_add_servicer_to_server_fn = add_ServerAppIoServicer_to_server diff --git a/framework/py/flwr/server/superlink/serverappio/serverappio_servicer.py b/framework/py/flwr/server/superlink/serverappio/serverappio_servicer.py index 3e1f58e89fb6..9afeec623a7d 100644 --- a/framework/py/flwr/server/superlink/serverappio/serverappio_servicer.py +++ b/framework/py/flwr/server/superlink/serverappio/serverappio_servicer.py @@ -80,7 +80,6 @@ from flwr.server.superlink.linkstate import LinkState, LinkStateFactory from flwr.server.superlink.utils import abort_if from flwr.server.utils.validator import validate_message -from flwr.supercore.ffs import FfsFactory from flwr.supercore.inflatable.inflatable_object import ( UnexpectedObjectContentError, get_all_nested_objects, @@ -96,11 +95,9 @@ class ServerAppIoServicer(serverappio_pb2_grpc.ServerAppIoServicer): def __init__( self, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, ) -> None: self.state_factory = state_factory - self.ffs_factory = ffs_factory self.objectstore_factory = objectstore_factory def ListAppsToLaunch( @@ -321,16 +318,13 @@ def PullAppInputs( # Validate the token run_id = self._verify_token(request.token, context) - # Init access to Ffs - ffs = self.ffs_factory.ffs() - # Retrieve Context, Run and Fab for the run_id serverapp_ctxt = state.get_serverapp_context(run_id) runs = state.get_run_info(run_ids=[run_id]) run = runs[0] if runs else None fab = None if run and run.fab_hash: - if result := ffs.get(run.fab_hash): + if result := state.get_fab(run.fab_hash): fab = Fab(run.fab_hash, result[0], result[1]) if run and fab and serverapp_ctxt: # Update run status to RUNNING diff --git a/framework/py/flwr/server/superlink/serverappio/serverappio_servicer_test.py b/framework/py/flwr/server/superlink/serverappio/serverappio_servicer_test.py index 379658b010f8..cc6ebc316fa9 100644 --- a/framework/py/flwr/server/superlink/serverappio/serverappio_servicer_test.py +++ b/framework/py/flwr/server/superlink/serverappio/serverappio_servicer_test.py @@ -15,7 +15,6 @@ """ServerAppIoServicer tests.""" -import tempfile import unittest from datetime import timedelta from unittest.mock import Mock, patch @@ -86,7 +85,6 @@ from flwr.server.superlink.utils import _STATUS_TO_MSG from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME, NOOP_FEDERATION, RunType from flwr.supercore.date import now -from flwr.supercore.ffs import FfsFactory from flwr.supercore.inflatable.inflatable_object import ( get_all_nested_objects, get_object_id, @@ -148,17 +146,11 @@ class TestServerAppIoServicer(unittest.TestCase): # pylint: disable=R0902, R090 def setUp(self) -> None: """Initialize mock stub and server interceptor.""" - # Create a temporary directory - self.temp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 - self.addCleanup(self.temp_dir.cleanup) # Ensures cleanup after test - objectstore_factory = ObjectStoreFactory() state_factory = LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), objectstore_factory ) self.state = state_factory.state() - ffs_factory = FfsFactory(self.temp_dir.name) - self.ffs = ffs_factory.ffs() self.store = objectstore_factory.store() self.node_pk = b"fake public key" self.node_id = self.state.create_node( @@ -171,7 +163,6 @@ def setUp(self) -> None: self._server: grpc.Server = run_serverappio_api_grpc( SERVERAPPIO_API_DEFAULT_SERVER_ADDRESS, state_factory, - ffs_factory, objectstore_factory, None, ) @@ -245,7 +236,7 @@ def setUp(self) -> None: def tearDown(self) -> None: """Clean up grpc server.""" - self._server.stop(None) + self._server.stop(None).wait(timeout=2) def _transition_run_status(self, run_id: int, num_transitions: int) -> None: if num_transitions > 0: @@ -953,7 +944,7 @@ def test_run_status_transitions(self) -> None: """Test `RequestToken` and `PullAppInputs` transitions run status from PENDING to STARTING to RUNNING.""" # Prepare: Create a run with FAB - fab_hash = self.ffs.put(b"mock fab content", {}) + fab_hash = self.state.put_fab(b"mock fab content", {}) run_id = self._create_dummy_run(running=False, fab_hash=fab_hash) # Set serverapp context diff --git a/framework/py/flwr/server/superlink/simulation/simulation_servicer_test.py b/framework/py/flwr/server/superlink/simulation/simulation_servicer_test.py index f6fc2438a9e1..5d0142b34408 100644 --- a/framework/py/flwr/server/superlink/simulation/simulation_servicer_test.py +++ b/framework/py/flwr/server/superlink/simulation/simulation_servicer_test.py @@ -15,7 +15,6 @@ """SimulationIoServicer tests.""" -import tempfile import unittest from unittest.mock import Mock @@ -43,7 +42,6 @@ from flwr.server.superlink.simulation.simulationio_grpc import run_simulationio_api_grpc from flwr.server.superlink.utils import _STATUS_TO_MSG from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME, NOOP_FEDERATION, RunType -from flwr.supercore.ffs import FfsFactory from flwr.supercore.object_store import ObjectStoreFactory from flwr.superlink.federation import NoOpFederationManager @@ -53,23 +51,16 @@ class TestSimulationIoServicer(unittest.TestCase): # pylint: disable=R0902 def setUp(self) -> None: """Initialize mock stub and server interceptor.""" - # Create a temporary directory - self.temp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 - self.addCleanup(self.temp_dir.cleanup) # Ensures cleanup after test - state_factory = LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), ObjectStoreFactory() ) self.state = state_factory.state() - ffs_factory = FfsFactory(self.temp_dir.name) - self.ffs = ffs_factory.ffs() self.status_to_msg = _STATUS_TO_MSG self._server: grpc.Server = run_simulationio_api_grpc( SIMULATIONIO_API_DEFAULT_SERVER_ADDRESS, state_factory, - ffs_factory, None, ) @@ -92,7 +83,7 @@ def setUp(self) -> None: def tearDown(self) -> None: """Clean up grpc server.""" - self._server.stop(None) + self._server.stop(None).wait(timeout=2) def _transition_run_status(self, run_id: int, num_transitions: int) -> None: if num_transitions > 0: diff --git a/framework/py/flwr/server/superlink/simulation/simulationio_grpc.py b/framework/py/flwr/server/superlink/simulation/simulationio_grpc.py index f1fe7122bef8..1545f2235c07 100644 --- a/framework/py/flwr/server/superlink/simulation/simulationio_grpc.py +++ b/framework/py/flwr/server/superlink/simulation/simulationio_grpc.py @@ -26,7 +26,6 @@ add_SimulationIoServicer_to_server, ) from flwr.server.superlink.linkstate import LinkStateFactory -from flwr.supercore.ffs import FfsFactory from .simulationio_servicer import SimulationIoServicer @@ -34,14 +33,12 @@ def run_simulationio_api_grpc( address: str, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, certificates: tuple[bytes, bytes, bytes] | None, ) -> grpc.Server: """Run SimulationIo API (gRPC, request-response).""" # Create SimulationIo API gRPC server simulationio_servicer: grpc.Server = SimulationIoServicer( state_factory=state_factory, - ffs_factory=ffs_factory, ) simulationio_add_servicer_to_server_fn = add_SimulationIoServicer_to_server simulationio_grpc_server = generic_create_grpc_server( diff --git a/framework/py/flwr/server/superlink/simulation/simulationio_servicer.py b/framework/py/flwr/server/superlink/simulation/simulationio_servicer.py index de43e54feeac..95abfad04198 100644 --- a/framework/py/flwr/server/superlink/simulation/simulationio_servicer.py +++ b/framework/py/flwr/server/superlink/simulation/simulationio_servicer.py @@ -61,17 +61,13 @@ ) from flwr.server.superlink.linkstate import LinkStateFactory from flwr.server.superlink.utils import abort_if -from flwr.supercore.ffs import FfsFactory class SimulationIoServicer(simulationio_pb2_grpc.SimulationIoServicer): """SimulationIo API servicer.""" - def __init__( - self, state_factory: LinkStateFactory, ffs_factory: FfsFactory - ) -> None: + def __init__(self, state_factory: LinkStateFactory) -> None: self.state_factory = state_factory - self.ffs_factory = ffs_factory self.lock = threading.RLock() def ListAppsToLaunch( @@ -137,9 +133,8 @@ def PullAppInputs( ) -> PullAppInputsResponse: """Pull SimultionIo process inputs.""" log(DEBUG, "SimultionIoServicer.SimultionIoInputs") - # Init access to LinkState and Ffs + # Init access to LinkState state = self.state_factory.state() - ffs = self.ffs_factory.ffs() # Validate the token run_id = self._verify_token(request.token, context) @@ -152,7 +147,7 @@ def PullAppInputs( run = runs[0] if runs else None fab = None if run and run.fab_hash: - if result := ffs.get(run.fab_hash): + if result := state.get_fab(run.fab_hash): fab = Fab(run.fab_hash, result[0], result[1]) if run and fab and serverapp_ctxt: # Update run status to RUNNING diff --git a/framework/py/flwr/supercore/state/alembic/versions/rev_2026_03_20_add_fab_artifact_table.py b/framework/py/flwr/supercore/state/alembic/versions/rev_2026_03_20_add_fab_artifact_table.py new file mode 100644 index 000000000000..b66aeb131908 --- /dev/null +++ b/framework/py/flwr/supercore/state/alembic/versions/rev_2026_03_20_add_fab_artifact_table.py @@ -0,0 +1,50 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Add fab_artifact table. + +Revision ID: 5dc4cbfa4b37 +Revises: c8f4f6e2c1ad +Create Date: 2026-03-20 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# pylint: disable=no-member + +# revision identifiers, used by Alembic. +revision: str = "5dc4cbfa4b37" +down_revision: str | Sequence[str] | None = "c8f4f6e2c1ad" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "fab_artifact", + sa.Column("fab_hash", sa.String(), nullable=False), + sa.Column("content", sa.LargeBinary(), nullable=False), + sa.Column("verifications", sa.String(), nullable=False), + sa.Column("created_at", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("fab_hash"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("fab_artifact") diff --git a/framework/py/flwr/supercore/state/schema/README.md b/framework/py/flwr/supercore/state/schema/README.md index 2ce91c0cd6e9..da5fdca7abdb 100644 --- a/framework/py/flwr/supercore/state/schema/README.md +++ b/framework/py/flwr/supercore/state/schema/README.md @@ -15,6 +15,13 @@ erDiagram BLOB context "nullable" } + fab_artifact { + VARCHAR fab_hash PK + BLOB content + VARCHAR created_at + VARCHAR verifications + } + logs { INTEGER run_id FK "nullable" VARCHAR log "nullable" diff --git a/framework/py/flwr/supercore/state/schema/linkstate_tables.py b/framework/py/flwr/supercore/state/schema/linkstate_tables.py index d3e384f99906..2a106ef98a55 100644 --- a/framework/py/flwr/supercore/state/schema/linkstate_tables.py +++ b/framework/py/flwr/supercore/state/schema/linkstate_tables.py @@ -152,4 +152,16 @@ def create_linkstate_metadata() -> MetaData: Column("error", LargeBinary, nullable=True), ) + # -------------------------------------------------------------------------- + # Table: fab_artifact + # -------------------------------------------------------------------------- + Table( + "fab_artifact", + metadata, + Column("fab_hash", String, primary_key=True), + Column("content", LargeBinary, nullable=False), + Column("verifications", String, nullable=False), + Column("created_at", String, nullable=False), + ) + return metadata diff --git a/framework/py/flwr/superlink/servicer/control/control_grpc.py b/framework/py/flwr/superlink/servicer/control/control_grpc.py index 2128f4b2ef9e..beea0f211837 100644 --- a/framework/py/flwr/superlink/servicer/control/control_grpc.py +++ b/framework/py/flwr/superlink/servicer/control/control_grpc.py @@ -26,7 +26,6 @@ from flwr.common.logger import log from flwr.proto.control_pb2_grpc import add_ControlServicer_to_server from flwr.server.superlink.linkstate import LinkStateFactory -from flwr.supercore.ffs import FfsFactory from flwr.supercore.license_plugin import LicensePlugin from flwr.supercore.object_store import ObjectStoreFactory from flwr.superlink.artifact_provider import ArtifactProvider @@ -53,7 +52,6 @@ def get_license_plugin() -> LicensePlugin | None: def run_control_api_grpc( address: str, state_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, certificates: tuple[bytes, bytes, bytes] | None, authn_plugin: ControlAuthnPlugin, @@ -69,7 +67,6 @@ def run_control_api_grpc( control_servicer: grpc.Server = ControlServicer( linkstate_factory=state_factory, - ffs_factory=ffs_factory, objectstore_factory=objectstore_factory, authn_plugin=authn_plugin, artifact_provider=artifact_provider, diff --git a/framework/py/flwr/superlink/servicer/control/control_servicer.py b/framework/py/flwr/superlink/servicer/control/control_servicer.py index d41b3d0f3659..f3cf52f6e509 100644 --- a/framework/py/flwr/superlink/servicer/control/control_servicer.py +++ b/framework/py/flwr/superlink/servicer/control/control_servicer.py @@ -111,7 +111,6 @@ from flwr.server.superlink.linkstate import LinkState, LinkStateFactory from flwr.supercore.constant import NOOP_FEDERATION, PLATFORM_API_URL, RunType from flwr.supercore.error import rpc_error_translator -from flwr.supercore.ffs import FfsFactory from flwr.supercore.object_store import ObjectStore, ObjectStoreFactory from flwr.supercore.primitives.asymmetric import bytes_to_public_key, uses_nist_ec_curve from flwr.supercore.utils import parse_app_spec, request_download_link @@ -128,14 +127,12 @@ class ControlServicer(control_pb2_grpc.ControlServicer): def __init__( # pylint: disable=R0913, R0917 self, linkstate_factory: LinkStateFactory, - ffs_factory: FfsFactory, objectstore_factory: ObjectStoreFactory, authn_plugin: ControlAuthnPlugin, artifact_provider: ArtifactProvider | None = None, fleet_api_type: str | None = None, ) -> None: self.linkstate_factory = linkstate_factory - self.ffs_factory = ffs_factory self.objectstore_factory = objectstore_factory self.authn_plugin = authn_plugin self.artifact_provider = artifact_provider @@ -147,7 +144,6 @@ def StartRun( # pylint: disable=too-many-locals, too-many-branches, too-many-st """Create run ID.""" log(INFO, "ControlServicer.StartRun") state = self.linkstate_factory.state() - ffs = self.ffs_factory.ffs() verification_dict: dict[str, str] = {} if request.app_spec: @@ -196,7 +192,7 @@ def StartRun( # pylint: disable=too-many-locals, too-many-branches, too-many-st fab_file, verification_dict, ) - fab_hash = ffs.put(fab.content, fab.verifications) + fab_hash = state.put_fab(fab.content, fab.verifications) if fab_hash != fab.hash_str: raise ValueError( diff --git a/framework/py/flwr/superlink/servicer/control/control_servicer_test.py b/framework/py/flwr/superlink/servicer/control/control_servicer_test.py index 2ca35f988e47..819ebb9ed27b 100644 --- a/framework/py/flwr/superlink/servicer/control/control_servicer_test.py +++ b/framework/py/flwr/superlink/servicer/control/control_servicer_test.py @@ -19,7 +19,6 @@ import hashlib import json import os -import tempfile import time import unittest from datetime import datetime @@ -79,7 +78,6 @@ from flwr.proto.federation_pb2 import Account, Member # pylint: disable=E0611 from flwr.server.superlink.linkstate import LinkStateFactory from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME, NOOP_FEDERATION, RunType -from flwr.supercore.ffs import FfsFactory from flwr.supercore.primitives.asymmetric import generate_key_pairs, public_key_to_bytes from flwr.superlink.auth_plugin import NoOpControlAuthnPlugin from flwr.superlink.federation import NoOpFederationManager @@ -113,13 +111,11 @@ class TestControlServicer(unittest.TestCase): # pylint: disable=R0904 def setUp(self) -> None: """Set up test fixtures.""" self.store = Mock() - self.tmp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 objectstore_factory = Mock(store=Mock(return_value=self.store)) self.servicer = ControlServicer( linkstate_factory=LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), objectstore_factory ), - ffs_factory=FfsFactory(self.tmp_dir.name), objectstore_factory=objectstore_factory, authn_plugin=(authn_plugin := NoOpControlAuthnPlugin(Mock(), False)), ) @@ -130,10 +126,6 @@ def setUp(self) -> None: shared_account_info.set(account_info) self.state = self.servicer.linkstate_factory.state() - def tearDown(self) -> None: - """Clean up after tests.""" - self.tmp_dir.cleanup() - def _create_dummy_run(self, flwr_aid: str | None) -> int: return self.state.create_run( "flwr/demo", @@ -657,7 +649,6 @@ def setUp(self) -> None: self.linkstate_factory.state.return_value = self.state self.servicer = ControlServicer( linkstate_factory=self.linkstate_factory, - ffs_factory=Mock(), objectstore_factory=Mock(), authn_plugin=Mock(), ) @@ -749,21 +740,15 @@ class TestControlServicerAuth(unittest.TestCase): def setUp(self) -> None: """Set up test fixtures.""" - self.tmp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 self.servicer = ControlServicer( linkstate_factory=LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), Mock() ), - ffs_factory=FfsFactory(self.tmp_dir.name), objectstore_factory=Mock(), authn_plugin=Mock(), ) self.state = self.servicer.linkstate_factory.state() - def tearDown(self) -> None: - """Clean up after tests.""" - self.tmp_dir.cleanup() - def _create_dummy_run(self, flwr_aid: str | None) -> int: return self.state.create_run( "flwr/demo", @@ -921,13 +906,11 @@ class TestValidateFederationAndNodesInRequest(unittest.TestCase): def setUp(self) -> None: """Set up test fixtures.""" - self.tmp_dir = tempfile.TemporaryDirectory() # pylint: disable=R1732 objectstore_factory = Mock(store=Mock(return_value=Mock())) self.servicer = ControlServicer( linkstate_factory=LinkStateFactory( FLWR_IN_MEMORY_DB_NAME, NoOpFederationManager(), objectstore_factory ), - ffs_factory=FfsFactory(self.tmp_dir.name), objectstore_factory=objectstore_factory, authn_plugin=(authn_plugin := NoOpControlAuthnPlugin(Mock(), False)), ) @@ -938,10 +921,6 @@ def setUp(self) -> None: shared_account_info.set(account_info) self.state = self.servicer.linkstate_factory.state() - def tearDown(self) -> None: - """Clean up after tests.""" - self.tmp_dir.cleanup() - def _make_context(self) -> MagicMock: """Create a mock gRPC context that raises on abort.""" ctx = MagicMock(spec=grpc.ServicerContext)