diff --git a/coriolis/tests/minion_manager/__init__.py b/coriolis/tests/minion_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/coriolis/tests/minion_manager/rpc/__init__.py b/coriolis/tests/minion_manager/rpc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/coriolis/tests/minion_manager/rpc/data/make_minion_machine_allocation_subflow_for_action.yaml b/coriolis/tests/minion_manager/rpc/data/make_minion_machine_allocation_subflow_for_action.yaml new file mode 100644 index 00000000..b55765a1 --- /dev/null +++ b/coriolis/tests/minion_manager/rpc/data/make_minion_machine_allocation_subflow_for_action.yaml @@ -0,0 +1,201 @@ +## EXCEPTIONS + +# requires 2 machines, but only 1 is available +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 1 + minion_machines: + - allocation_status: AVAILABLE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + expect: + exception: InvalidMinionPoolState + +# requires 2 machines, but only 1 is available +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 2 + minion_machines: + - allocation_status: IN_USE + - allocation_status: AVAILABLE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + expect: + exception: InvalidMinionPoolState + +# maximum_minions is too low for 4 new machines +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 6 + minion_machines: + - id: machine_1 + allocation_status: IN_USE + - id: machine_2 + allocation_status: IN_USE + - id: machine_3 + allocation_status: IN_USE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + exception: InvalidMinionPoolState + +## SUCCESS + +# no new machines need to be allocated +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 4 + minion_machines: + - id: machine_1 + allocation_status: AVAILABLE + - id: machine_2 + allocation_status: AVAILABLE + - id: machine_3 + allocation_status: AVAILABLE + - id: machine_4 + allocation_status: AVAILABLE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + result: + mappings: + instance_1: machine_1 + instance_2: machine_2 + instance_3: machine_3 + instance_4: machine_4 + +# 2 new machines are allocated +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 5 + minion_machines: + - id: machine_1 + allocation_status: AVAILABLE + - id: machine_2 + allocation_status: IN_USE + - id: machine_3 + allocation_status: AVAILABLE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + result: + mappings: + instance_1: machine_1 + instance_2: machine_3 + instance_3: new_machine + instance_4: new_machine + +# 3 new machines are allocated +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 6 + minion_machines: + - id: machine_1 + allocation_status: AVAILABLE + - id: machine_2 + allocation_status: IN_USE + - id: machine_3 + allocation_status: IN_USE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + result: + mappings: + instance_1: machine_1 + instance_2: new_machine + instance_3: new_machine + instance_4: new_machine + +# 4 new machines are allocated +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 7 + minion_machines: + - id: machine_1 + allocation_status: IN_USE + - id: machine_2 + allocation_status: IN_USE + - id: machine_3 + allocation_status: IN_USE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + result: + mappings: + instance_1: new_machine + instance_2: new_machine + instance_3: new_machine + instance_4: new_machine + +# 1 allocated +- config: + minion_pool: + id: minion_pool_1 + maximum_minions: 4 + minion_machines: + - id: machine_1 + allocation_status: AVAILABLE + action_instances: + instance_1: + name: Instance 1 + instance_2: + name: Instance 2 + instance_3: + name: Instance 3 + instance_4: + name: Instance 4 + expect: + result: + mappings: + instance_1: machine_1 + instance_2: new_machine + instance_3: new_machine + instance_4: new_machine \ No newline at end of file diff --git a/coriolis/tests/minion_manager/rpc/data/validate_minion_pool_selections_for_action_config.yaml b/coriolis/tests/minion_manager/rpc/data/validate_minion_pool_selections_for_action_config.yaml new file mode 100644 index 00000000..21cc7d80 --- /dev/null +++ b/coriolis/tests/minion_manager/rpc/data/validate_minion_pool_selections_for_action_config.yaml @@ -0,0 +1,352 @@ +# missing destination_endpoint_id +- config: + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: [] + expected_exception: InvalidInput + +# action must be a dict +- config: + action: 1 + expected_exception: InvalidInput + +# valid action +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: ~ + +# could not find minion pool (1) +- config: + minion_pools: + - id: "invalid id" + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: NotFound + +# could not find minion pool (2) +- config: + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: NotFound + +# maxmimum minions is less than number of instances +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 1 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +# minion pool is not allocated +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: DEALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolState + +## ORIGIN TESTS ## + +# origin endpoint id does not match with minion pool endpoint id +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "invalid id" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +# origin pool platform is not source +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +# origin minion pool is not linux +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: windows + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +## DESTINATION TESTS ## + +# destination endpoint id does not match with minion pool endpoint id +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "invalid id" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +# destination pool platform is not destination +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +# destination minion pool is not linux +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: windows + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: {} + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection + +## OSMORPHING TESTS ## + +# valid action with os morphing mappings (1) +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: + instance-1: "destination-pool-1" + instances: ["instance-1", "instance-2"] + expected_exception: ~ + +# valid action with os morphing mappings (2) +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: + instance-1: "destination-pool-1" + instance-2: "destination-pool-1" + instances: ["instance-1", "instance-2"] + expected_exception: ~ + +# pool belongs to origin endpoint +- config: + minion_pools: + - id: "origin-pool-1" + endpoint_id: "origin-endpoint-1" + platform: source + os_type: linux + status: ALLOCATED + maximum_minions: 2 + - id: "destination-pool-1" + endpoint_id: "destination-endpoint-1" + platform: destination + os_type: linux + status: ALLOCATED + maximum_minions: 3 + action: + id: "action-1" + origin_endpoint_id: "origin-endpoint-1" + destination_endpoint_id: "destination-endpoint-1" + origin_minion_pool_id: "origin-pool-1" + destination_minion_pool_id: "destination-pool-1" + instance_osmorphing_minion_pool_mappings: + instance-1: "origin-pool-1" + instance-2: "destination-pool-1" + instances: ["instance-1", "instance-2"] + expected_exception: InvalidMinionPoolSelection \ No newline at end of file diff --git a/coriolis/tests/minion_manager/rpc/test_server.py b/coriolis/tests/minion_manager/rpc/test_server.py new file mode 100644 index 00000000..ae54ab95 --- /dev/null +++ b/coriolis/tests/minion_manager/rpc/test_server.py @@ -0,0 +1,141 @@ +# Copyright 2023 Cloudbase Solutions Srl +# All Rights Reserved. + +import uuid + +from unittest import mock + +import ddt + +from coriolis import exception, constants +from coriolis.db import api as db_api +from coriolis.tests import test_base, testutils +from coriolis.minion_manager.rpc import server + + +@ddt.ddt +class MinionManagerServerEndpointTestCase(test_base.CoriolisBaseTestCase): + """Test suite for the Coriolis Minion Manager RPC server.""" + + def setUp(self, *_, **__): + super(MinionManagerServerEndpointTestCase, self).setUp() + self.server = server.MinionManagerServerEndpoint() + + @mock.patch.object(db_api, "get_minion_pools") + @ddt.file_data( + "data/validate_minion_pool_selections_for_action_config.yaml" + ) + @ddt.unpack + def test_validate_minion_pool_selections_for_action( + self, + mock_get_minion_pools, + config, + expected_exception, + ): + action = config.get("action") + minion_pools = config.get("minion_pools", []) + + mock_get_minion_pools.return_value = [ + mock.MagicMock( + **pool, + ) for pool in minion_pools + ] + + if expected_exception: + exception_mapping = { + "InvalidInput": exception + .InvalidInput, + "NotFound": exception + .NotFound, + "InvalidMinionPoolSelection": exception + .InvalidMinionPoolSelection, + "InvalidMinionPoolState": exception + .InvalidMinionPoolState, + } + exception_type = exception_mapping.get(expected_exception) + self.assertRaises( + exception_type, + self.server.validate_minion_pool_selections_for_action, + mock.sentinel.context, + action, + ) + return + + self.server.validate_minion_pool_selections_for_action( + mock.sentinel.context, + action, + ) + + @mock.patch.object(uuid, "uuid4", return_value="new_machine") + @mock.patch.object( + server.MinionManagerServerEndpoint, + "_add_minion_pool_event") + @mock.patch.object(db_api, "add_minion_machine") + @mock.patch.object(db_api, "set_minion_machines_allocation_statuses") + @ddt.file_data( + "data/make_minion_machine_allocation_subflow_for_action.yaml" + ) + @ddt.unpack + def test_make_minion_machine_allocation_subflow_for_action( + self, + mock_set_minion_machines_allocation_statuses, + mock_add_minion_machine, + mock_add_minion_pool_event, + mock_uuid4, + config, + expect + ): + expected_exception = expect.get("exception") + expected_result = expect.get("result") + + minion_pool = testutils.DictToObject(config.get("minion_pool")) + action_instances = config.get("action_instances") + + args = [ + mock.sentinel.context, + minion_pool, + mock.sentinel.action_id, + action_instances, + mock.sentinel.subflow_name, + ] + + if expected_exception: + exception_mapping = { + "InvalidMinionPoolState": exception + .InvalidMinionPoolState, + } + exception_type = exception_mapping.get(expected_exception) + self.assertRaises( + exception_type, + self.server._make_minion_machine_allocation_subflow_for_action, + *args) + return + + result = self.server\ + ._make_minion_machine_allocation_subflow_for_action( + *args) + + mappings = expected_result.get("mappings") + + num_new_machines = list(mappings.values()).count("new_machine") + + # db_api.add_minion_machine is called once for each new machine + self.assertEqual( + num_new_machines, + mock_add_minion_machine.call_count) + + num_non_new_machines = len(mappings) - num_new_machines + if num_non_new_machines: + # db_api.set_minion_machines_allocation_statuses is called once + # with the non-new machines + mock_set_minion_machines_allocation_statuses\ + .assert_called_once_with( + mock.sentinel.context, + list(mappings.values())[:num_non_new_machines], + mock.sentinel.action_id, + constants.MINION_MACHINE_STATUS_RESERVED, + refresh_allocation_time=True) + + self.assertEqual( + mappings, + result.get("action_instance_minion_allocation_mappings")) diff --git a/coriolis/tests/testutils.py b/coriolis/tests/testutils.py index f98d914d..c822e3a0 100644 --- a/coriolis/tests/testutils.py +++ b/coriolis/tests/testutils.py @@ -34,3 +34,36 @@ def _get_wrapped_function(function): return function return _get_wrapped_function(function) + + +class DictToObject: + """Converts a dictionary to an object with attributes. + + This is useful for mocking objects that are used as configuration + objects. + """ + + def __init__(self, dictionary, skip_attrs=None): + if skip_attrs is None: + skip_attrs = [] + + for key, value in dictionary.items(): + if key in skip_attrs: + setattr(self, key, value) + elif isinstance(value, dict): + setattr(self, key, DictToObject(value, skip_attrs=skip_attrs)) + elif isinstance(value, list): + setattr( + self, key, + [DictToObject(item, skip_attrs=skip_attrs) if isinstance( + item, dict) else item for item in value]) + else: + setattr(self, key, value) + + def __getattr__(self, item): + return None + + def __repr__(self): + attrs = [f"{k}={v!r}" for k, v in self.__dict__.items()] + attrs_str = ', '.join(attrs) + return f"{self.__class__.__name__}({attrs_str})"