diff --git a/coriolis/api-refs/api_samples/endpoint/endpoint-validate-connection-req.json b/coriolis/api-refs/api_samples/endpoint/endpoint-validate-connection-req.json index 6fedc014d..2ff3152bc 100644 --- a/coriolis/api-refs/api_samples/endpoint/endpoint-validate-connection-req.json +++ b/coriolis/api-refs/api_samples/endpoint/endpoint-validate-connection-req.json @@ -1,3 +1,16 @@ { - "validate-connection": null + "validate-connection": { + "platform": "openstack", + "connection_info": { + "identity_api_version": 3, + "auth_url": "http://openstack.awesome.our:5000/v3", + "username": "admin", + "password": "Passw0rd", + "project_name": "admin", + "user_domain_name": "default", + "project_domain_name": "default", + "allow_untrusted": true + }, + "mapped_regions": [] + } } diff --git a/coriolis/api-refs/source/endpoint.inc b/coriolis/api-refs/source/endpoint.inc index 762ebc6cb..30dbb3d8a 100644 --- a/coriolis/api-refs/source/endpoint.inc +++ b/coriolis/api-refs/source/endpoint.inc @@ -556,18 +556,15 @@ Endpoint Actions Validate Endpoint Connection ============================ -.. rest_method:: POST /endpoints/{endpoint_id}/actions - -Validates connection to endpoint. - -**Preconditions** - -The endpoint must exist. +.. rest_method:: POST /endpoints/actions +Validates the connection info for an endpoint platform, without requiring the +endpoint to exist. Nothing is persisted, so this can be used to validate the +connection info of an endpoint before creating or updating it. Normal response codes: 200 -Error response codes: unauthorized(401), forbidden(403), +Error response codes: badRequest(400), unauthorized(401), forbidden(403), itemNotFound(404) Request @@ -576,7 +573,9 @@ Request .. rest_parameters:: parameters.yaml - validate-connection : validate_connection - - endpoint_id : endpoint_id_path + - platform : validate_connection_platform + - connection_info : connection_info + - mapped_regions : validate_connection_mapped_regions **Example of Endpoint Validate Connection Request** .. literalinclude:: ../api_samples/endpoint/endpoint-validate-connection-req.json diff --git a/coriolis/api-refs/source/parameters.yaml b/coriolis/api-refs/source/parameters.yaml index 6b982029d..ad53d7f66 100644 --- a/coriolis/api-refs/source/parameters.yaml +++ b/coriolis/api-refs/source/parameters.yaml @@ -1478,11 +1478,24 @@ valid_connection: required: true validate_connection: description: | - An object that contains the validate connection action for the - endpoint connection. + An object that contains the validate connection action, holding the + endpoint platform and connection info to be validated. in: body type: object required: true +validate_connection_mapped_regions: + description: | + List of IDs of the Coriolis regions whose workers should be used to + validate the connection. Any worker can be used if omitted. + in: body + type: array + required: false +validate_connection_platform: + description: | + The type of cloud service platform the connection info is for. + in: body + type: string + required: true validation_message: description: | The error message in case of endpoint validation failure. diff --git a/coriolis/api/v1/endpoint_actions.py b/coriolis/api/v1/endpoint_actions.py index f04c6b8b3..4933f6ca8 100644 --- a/coriolis/api/v1/endpoint_actions.py +++ b/coriolis/api/v1/endpoint_actions.py @@ -5,6 +5,7 @@ from coriolis import exception from coriolis.api import wsgi as api_wsgi +from coriolis.api.v1 import utils as api_utils from coriolis.endpoints import api from coriolis.policies import endpoints as endpoint_policies @@ -14,14 +15,30 @@ def __init__(self): self._endpoint_api = api.API() super(EndpointActionsController, self).__init__() + @api_utils.format_keyerror_message(resource='endpoint', method='validate') + def _validate_connection_body(self, body): + validate_connection = body["validate-connection"] + if not isinstance(validate_connection, dict): + raise exception.InvalidInput( + 'The "validate-connection" body must be an object containing ' + 'the "platform" and "connection_info" of the endpoint' + ) + platform = validate_connection["platform"] + connection_info = validate_connection["connection_info"] + mapped_regions = validate_connection.get("mapped_regions", []) + return (platform, connection_info, mapped_regions) + @api_wsgi.action('validate-connection') - def _validate_connection(self, req, id, body): + def _validate_endpoint(self, req, body): context = req.environ['coriolis.context'] context.can( "%s:validate_connection" % (endpoint_policies.ENDPOINTS_POLICY_PREFIX) ) + platform, connection_info, mapped_regions = self._validate_connection_body(body) try: - is_valid, message = self._endpoint_api.validate_connection(context, id) + is_valid, message = self._endpoint_api.validate_connection( + context, platform, connection_info, mapped_regions + ) return {"validate-connection": {"valid": is_valid, "message": message}} except exception.NotFound as ex: raise exc.HTTPNotFound(explanation=ex.msg) diff --git a/coriolis/api/v1/router.py b/coriolis/api/v1/router.py index 53497a536..c4daca13b 100644 --- a/coriolis/api/v1/router.py +++ b/coriolis/api/v1/router.py @@ -115,10 +115,9 @@ def _setup_routes(self, mapper, ext_mgr): endpoint_actions_resource = endpoint_actions.create_resource() self.resources['endpoint_actions'] = endpoint_actions_resource - endpoint_path = '/{project_id}/endpoints/{id}' mapper.connect( 'endpoint_actions', - endpoint_path + '/actions', + '/{project_id}/endpoints/actions', controller=self.resources['endpoint_actions'], action='action', conditions={'method': 'POST'}, diff --git a/coriolis/api/v1/utils.py b/coriolis/api/v1/utils.py index d660c207f..b80f5b0f1 100644 --- a/coriolis/api/v1/utils.py +++ b/coriolis/api/v1/utils.py @@ -68,6 +68,7 @@ def _build_keyerror_message(resource, method, key): method_mapping = { "create": "creation", "update": "update", + "validate": "validation", } if resource == key: diff --git a/coriolis/conductor/rpc/client.py b/coriolis/conductor/rpc/client.py index 57f33704f..e29cb2aa4 100644 --- a/coriolis/conductor/rpc/client.py +++ b/coriolis/conductor/rpc/client.py @@ -126,8 +126,16 @@ def get_endpoint_inventory_csv(self, ctxt, endpoint_id, source_environment): source_environment=source_environment, ) - def validate_endpoint_connection(self, ctxt, endpoint_id): - return self._call(ctxt, 'validate_endpoint_connection', endpoint_id=endpoint_id) + def validate_endpoint_connection( + self, ctxt, platform, connection_info, mapped_regions + ): + return self._call( + ctxt, + 'validate_endpoint_connection', + platform=platform, + connection_info=connection_info, + mapped_regions=mapped_regions, + ) def validate_endpoint_target_environment(self, ctxt, endpoint_id, target_env): return self._call( diff --git a/coriolis/conductor/rpc/server.py b/coriolis/conductor/rpc/server.py index 5655ad608..8d762388f 100644 --- a/coriolis/conductor/rpc/server.py +++ b/coriolis/conductor/rpc/server.py @@ -722,19 +722,17 @@ def get_endpoint_inventory_csv(self, ctxt, endpoint_id, source_environment): ctxt, endpoint.type, endpoint.connection_info, source_environment ) - def validate_endpoint_connection(self, ctxt, endpoint_id): - endpoint = self.get_endpoint(ctxt, endpoint_id) - + def validate_endpoint_connection( + self, ctxt, platform, connection_info, mapped_regions=None + ): worker_rpc = self._get_worker_service_rpc_for_specs( ctxt, enabled=True, - region_sets=[[reg.id for reg in endpoint.mapped_regions]], - provider_requirements={endpoint.type: [constants.PROVIDER_TYPE_ENDPOINT]}, + region_sets=[mapped_regions or []], + provider_requirements={platform: [constants.PROVIDER_TYPE_ENDPOINT]}, ) - return worker_rpc.validate_endpoint_connection( - ctxt, endpoint.type, endpoint.connection_info - ) + return worker_rpc.validate_endpoint_connection(ctxt, platform, connection_info) def validate_endpoint_target_environment(self, ctxt, endpoint_id, target_env): endpoint = self.get_endpoint(ctxt, endpoint_id) diff --git a/coriolis/endpoints/api.py b/coriolis/endpoints/api.py index 7b5b05331..044db7597 100644 --- a/coriolis/endpoints/api.py +++ b/coriolis/endpoints/api.py @@ -32,9 +32,9 @@ def get_endpoints(self, ctxt): def get_endpoint(self, ctxt, endpoint_id): return self._rpc_conductor_client.get_endpoint(ctxt, endpoint_id) - def validate_connection(self, ctxt, endpoint_id): + def validate_connection(self, ctxt, platform, connection_info, mapped_regions): return self._rpc_conductor_client.validate_endpoint_connection( - ctxt, endpoint_id + ctxt, platform, connection_info, mapped_regions ) @utils.bad_request_on_error("Invalid destination environment: %s") diff --git a/coriolis/policies/endpoints.py b/coriolis/policies/endpoints.py index fd54493cd..9d8ffaa7e 100644 --- a/coriolis/policies/endpoints.py +++ b/coriolis/policies/endpoints.py @@ -50,7 +50,7 @@ def get_endpoints_policy_label(rule_label): get_endpoints_policy_label('validate_connection'), ENDPOINTS_POLICY_DEFAULT_RULE, "Validate endpoint connection info", - [{"path": "/endpoints/{endpoint_id}/actions", "method": "POST"}], + [{"path": "/endpoints/actions", "method": "POST"}], ), policy.DocumentedRuleDefault( get_endpoints_policy_label('list_instances'), diff --git a/coriolis/tests/api/v1/test_endpoint_actions.py b/coriolis/tests/api/v1/test_endpoint_actions.py index 203022057..57e2dbfba 100644 --- a/coriolis/tests/api/v1/test_endpoint_actions.py +++ b/coriolis/tests/api/v1/test_endpoint_actions.py @@ -18,13 +18,89 @@ def setUp(self): super(EndpointActionsControllerTestCase, self).setUp() self.endpoint_api = endpoint_actions.EndpointActionsController() + def test__validate_connection_body(self): + body = { + "validate-connection": { + "platform": "mock_platform", + "connection_info": "mock_connection_info", + "mapped_regions": ["mock_region"], + } + } + + result = testutils.get_wrapped_function( + self.endpoint_api._validate_connection_body + )( + self.endpoint_api, + body, # type: ignore + ) + + self.assertEqual( + ("mock_platform", "mock_connection_info", ["mock_region"]), result + ) + + def test__validate_connection_body_no_mapped_regions(self): + body = { + "validate-connection": { + "platform": "mock_platform", + "connection_info": "mock_connection_info", + } + } + + result = testutils.get_wrapped_function( + self.endpoint_api._validate_connection_body + )( + self.endpoint_api, + body, # type: ignore + ) + + self.assertEqual(("mock_platform", "mock_connection_info", []), result) + + def test__validate_connection_body_not_dict(self): + body = {"validate-connection": None} + + self.assertRaises( + exception.InvalidInput, + testutils.get_wrapped_function(self.endpoint_api._validate_connection_body), + self.endpoint_api, + body, + ) + + def test__validate_connection_body_missing_platform(self): + body = {"validate-connection": {"connection_info": "mock_connection_info"}} + + self.assertRaises( + KeyError, + testutils.get_wrapped_function(self.endpoint_api._validate_connection_body), + self.endpoint_api, + body, + ) + + def test__validate_connection_body_missing_connection_info(self): + body = {"validate-connection": {"platform": "mock_platform"}} + + self.assertRaises( + KeyError, + testutils.get_wrapped_function(self.endpoint_api._validate_connection_body), + self.endpoint_api, + body, + ) + @mock.patch.object(api.API, 'validate_connection') - def test_validate_connection(self, mock_validate_connection): + @mock.patch.object( + endpoint_actions.EndpointActionsController, '_validate_connection_body' + ) + def test_validate_endpoint( + self, mock__validate_connection_body, mock_validate_connection + ): mock_req = mock.Mock() mock_context = mock.Mock() mock_req.environ = {'coriolis.context': mock_context} - id = mock.sentinel.id body = mock.sentinel.body + mock__validate_connection_body.return_value = ( + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) is_valid = True message = 'mock_message' mock_validate_connection.return_value = (is_valid, message) @@ -32,54 +108,83 @@ def test_validate_connection(self, mock_validate_connection): expected_result = { "validate-connection": {"valid": is_valid, "message": message} } - result = testutils.get_wrapped_function(self.endpoint_api._validate_connection)( + result = testutils.get_wrapped_function(self.endpoint_api._validate_endpoint)( mock_req, - id, body, # type: ignore ) mock_context.can.assert_called_once_with( 'migration:endpoints:validate_connection' ) - mock_validate_connection.assert_called_once_with(mock_context, id) + mock__validate_connection_body.assert_called_once_with(body) + mock_validate_connection.assert_called_once_with( + mock_context, + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) self.assertEqual(expected_result, result) @mock.patch.object(api.API, 'validate_connection') - def test_validate_connection_except_not_found(self, mock_validate_connection): + @mock.patch.object( + endpoint_actions.EndpointActionsController, '_validate_connection_body' + ) + def test_validate_endpoint_except_not_found( + self, mock__validate_connection_body, mock_validate_connection + ): mock_req = mock.Mock() mock_context = mock.Mock() mock_req.environ = {'coriolis.context': mock_context} - id = mock.sentinel.id body = mock.sentinel.body + mock__validate_connection_body.return_value = ( + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) mock_validate_connection.side_effect = exception.NotFound self.assertRaises( exc.HTTPNotFound, - testutils.get_wrapped_function(self.endpoint_api._validate_connection), + testutils.get_wrapped_function(self.endpoint_api._validate_endpoint), mock_req, - id, body, ) - mock_validate_connection.assert_called_once_with(mock_context, id) + mock_validate_connection.assert_called_once_with( + mock_context, + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) @mock.patch.object(api.API, 'validate_connection') - def test_validate_connection_except_invalid_parameter_value( - self, mock_validate_connection + @mock.patch.object( + endpoint_actions.EndpointActionsController, '_validate_connection_body' + ) + def test_validate_endpoint_except_invalid_parameter_value( + self, mock__validate_connection_body, mock_validate_connection ): mock_req = mock.Mock() mock_context = mock.Mock() mock_req.environ = {'coriolis.context': mock_context} - id = mock.sentinel.id body = mock.sentinel.body + mock__validate_connection_body.return_value = ( + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) mock_validate_connection.side_effect = exception.InvalidParameterValue( "mock_err" ) self.assertRaises( exc.HTTPNotFound, - testutils.get_wrapped_function(self.endpoint_api._validate_connection), + testutils.get_wrapped_function(self.endpoint_api._validate_endpoint), mock_req, - id, body, ) - mock_validate_connection.assert_called_once_with(mock_context, id) + mock_validate_connection.assert_called_once_with( + mock_context, + mock.sentinel.platform, + mock.sentinel.connection_info, + mock.sentinel.mapped_regions, + ) diff --git a/coriolis/tests/api/v1/test_router.py b/coriolis/tests/api/v1/test_router.py index 4a8a9128d..94314e402 100644 --- a/coriolis/tests/api/v1/test_router.py +++ b/coriolis/tests/api/v1/test_router.py @@ -207,7 +207,7 @@ def test_setup_routes( ), mock.call( 'endpoint_actions', - '/{project_id}/endpoints/{id}/actions', + '/{project_id}/endpoints/actions', controller=mock_endpoint_actions_create_resource.return_value, action='action', conditions={'method': 'POST'}, diff --git a/coriolis/tests/conductor/rpc/test_client.py b/coriolis/tests/conductor/rpc/test_client.py index bdf4c93b6..ce077b29f 100644 --- a/coriolis/tests/conductor/rpc/test_client.py +++ b/coriolis/tests/conductor/rpc/test_client.py @@ -115,7 +115,11 @@ def test_get_endpoint_storage(self): self._test(self.client.get_endpoint_storage, args) def test_validate_endpoint_connection(self): - args = {"endpoint_id": "mock_endpoint_id"} + args = { + "platform": "mock_platform", + "connection_info": "mock_connection_info", + "mapped_regions": "mock_mapped_regions", + } self._test(self.client.validate_endpoint_connection, args) def test_validate_endpoint_target_environment(self): diff --git a/coriolis/tests/conductor/rpc/test_server.py b/coriolis/tests/conductor/rpc/test_server.py index 10e5ef423..69a17ca97 100644 --- a/coriolis/tests/conductor/rpc/test_server.py +++ b/coriolis/tests/conductor/rpc/test_server.py @@ -590,28 +590,31 @@ def test_get_endpoint_inventory_csv( def test_validate_endpoint_connection( self, mock_get_endpoint, mock_get_worker_service_rpc_for_specs ): - self.server.validate_endpoint_connection( - mock.sentinel.context, mock.sentinel.endpoint_id - ) - - mock_get_endpoint.assert_called_once_with( - mock.sentinel.context, mock.sentinel.endpoint_id + result = self.server.validate_endpoint_connection( + mock.sentinel.context, + mock.sentinel.platform, + mock.sentinel.connection_info, + ["mock_region"], ) + mock_get_endpoint.assert_not_called() mock_get_worker_service_rpc_for_specs.assert_called_once_with( mock.sentinel.context, enabled=True, - region_sets=[[]], + region_sets=[["mock_region"]], provider_requirements={ - mock_get_endpoint.return_value.type: [constants.PROVIDER_TYPE_ENDPOINT] + mock.sentinel.platform: [constants.PROVIDER_TYPE_ENDPOINT] }, ) rpc_return_value = mock_get_worker_service_rpc_for_specs.return_value rpc_return_value.validate_endpoint_connection.assert_called_once_with( mock.sentinel.context, - mock_get_endpoint.return_value.type, - mock_get_endpoint.return_value.connection_info, + mock.sentinel.platform, + mock.sentinel.connection_info, + ) + self.assertEqual( + rpc_return_value.validate_endpoint_connection.return_value, result ) @mock.patch.object( diff --git a/coriolis/tests/endpoints/test_api.py b/coriolis/tests/endpoints/test_api.py index e19068f3f..3340f5313 100644 --- a/coriolis/tests/endpoints/test_api.py +++ b/coriolis/tests/endpoints/test_api.py @@ -78,7 +78,12 @@ def test_get_endpoint(self): ) def test_validate_connection(self): - args = {"ctxt": mock.sentinel.ctxt, "endpoint_id": mock.sentinel.endpoint_id} + args = { + "ctxt": mock.sentinel.ctxt, + "platform": mock.sentinel.platform, + "connection_info": mock.sentinel.connection_info, + "mapped_regions": mock.sentinel.mapped_regions, + } result = self.endpoints_api.validate_connection(**args) ( self.endpoints_api._rpc_conductor_client.validate_endpoint_connection.assert_called_once_with diff --git a/coriolis/tests/integration/test_endpoints.py b/coriolis/tests/integration/test_endpoints.py index 093c2382a..71bfae268 100644 --- a/coriolis/tests/integration/test_endpoints.py +++ b/coriolis/tests/integration/test_endpoints.py @@ -39,26 +39,27 @@ def setUp(self): def test_validate_connection(self): valid, message = self._client.endpoints.validate_connection( - self._src_endpoint.id + self._exp_platform, self._exp_conn_info ) self.assertTrue(valid, f"source: {message}") valid, message = self._client.endpoints.validate_connection( - self._dst_endpoint.id + self._imp_platform, self._imp_conn_info ) self.assertTrue(valid, f"destination: {message}") def test_validate_connection_failure(self): - bad_endpoint = self._create_endpoint( - name="cap-bad", - endpoint_type=self._exp_platform, - connection_info={ + endpoints_count = len(self._client.endpoints.list()) + + valid, message = self._client.endpoints.validate_connection( + self._exp_platform, + { "pkey_path": "/root/.ssh/coriolis-no-such-key", }, ) - valid, message = self._client.endpoints.validate_connection(bad_endpoint.id) self.assertFalse(valid) self.assertIsNotNone(message) + self.assertEqual(endpoints_count, len(self._client.endpoints.list())) def test_endpoint_update(self): updated = self._client.endpoints.update(