From 873a87681a6bc918c0ac347f71e1ad55c61943a6 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Thu, 22 Oct 2026 15:09:17 +0300 Subject: [PATCH] Register common destination options and apply them during OS morphing. Add data_transfer_mechanism, set_dhcp, and retain_user_credentials under common_target_options. The worker merges those rows onto each provider list, injects the fields into the destination schema, and removes core names before the provider call. A request that names only core options returns only those rows. OS morphing stores set_dhcp as nics_set_dhcp and stores retain_user_credentials on the morphing parameters. --- coriolis/providers/conf.py | 313 ++++++++++++++++++ .../schemas/os_morphing_resources_schema.json | 6 +- coriolis/tasks/osmorphing_tasks.py | 6 + coriolis/tests/osmorphing/test_manager.py | 291 ++++++++++++++++ coriolis/tests/tasks/test_osmorphing_tasks.py | 11 +- coriolis/tests/worker/rpc/test_server.py | 59 +++- coriolis/worker/rpc/server.py | 30 +- etc/coriolis/coriolis.conf | 14 + requirements.txt | 1 + 9 files changed, 715 insertions(+), 16 deletions(-) create mode 100644 coriolis/providers/conf.py diff --git a/coriolis/providers/conf.py b/coriolis/providers/conf.py new file mode 100644 index 000000000..b1ec42822 --- /dev/null +++ b/coriolis/providers/conf.py @@ -0,0 +1,313 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Provider-agnostic destination options registered with oslo.config. + +Core dest-options are a list of wizard rows. The worker merges the +provider list onto that catalog with jsonmerge arrayMergeById on name. +The provider overwrites matching fields. Core rows always stay. +Provider-written schema properties are kept. +Destination environment values take precedence over provider values. +Provider values take precedence over coriolis.conf. +The oslo options are in the common_target_options group. +""" + +import copy + +from jsonmerge import Merger +from oslo_config import cfg +from oslo_log import log as logging + +from coriolis import exception + +COMMON_TARGET_OPTION_GROUP = "common_target_options" + +CORE_SCHEMA_PROPERTIES = { + "data_transfer_mechanism": { + "type": "string", + "title": "Data Transfer Mechanism", + "enum": ["SSH", "HTTPS"], + "description": ( + "What mechanism to use when sending disk data from the " + "Coriolis installation to the temporary VMs on the " + "destination to be written to their respective disk. " + "The HTTPS-based transfer mechanism (TCP/5566) is faster " + "but might not work if there are firewalls in the way. " + "The SSH-based transfer mechanism (TCP/22) is more costly " + "but will be allowed by most firewalls since SSH access " + "from the Coriolis installation to the temporary worker " + "VM is always required. Default is HTTPS." + ), + }, + "set_dhcp": { + "type": "boolean", + "title": "Set DHCP", + "description": ( + "Sets whether or not to configure the VM to use DHCP " + "during the OSMorphing stage. " + "When disabled, guest static IPs will be preserved " + "after the migration." + ), + }, + "retain_user_credentials": { + "type": "boolean", + "title": "Retain User Credentials", + "description": ( + "Prevents the destination cloud provisioning from resetting " + "the pre-existing credentials. " + "When disabled, the destination platform may also create " + "a provisioned cloud guest user." + ), + }, +} + +CORE_DESTINATION_OPTIONS = { + "data_transfer_mechanism": { + "name": "data_transfer_mechanism", + "values": ["SSH", "HTTPS"], + "config_default": "HTTPS", + }, + "set_dhcp": { + "name": "set_dhcp", + "values": [], + "config_default": True, + }, + "retain_user_credentials": { + "name": "retain_user_credentials", + "values": [], + "config_default": False, + }, +} + +# option: name in the destination environment and in coriolis.conf. +# field: stored name. It can differ from option. +# target "parameters": write the field on osmorphing_parameters. +# target "info": write the field on osmorphing_info. +OSMORPHING_OVERRIDES = ( + { + "option": "set_dhcp", + "field": "nics_set_dhcp", + "target": "info", + }, + { + "option": "retain_user_credentials", + "field": "retain_user_credentials", + "target": "parameters", + }, +) + +_DEST_OPTIONS_MERGER = Merger( + { + "mergeStrategy": "arrayMergeById", + "mergeOptions": {"idRef": "/name"}, + "items": { + "type": "object", + "mergeStrategy": "objectMerge", + }, + } +) + +opts = [ + cfg.BoolOpt( + "set_dhcp", + default=CORE_DESTINATION_OPTIONS["set_dhcp"]["config_default"], + help=CORE_SCHEMA_PROPERTIES["set_dhcp"]["description"], + ), + cfg.BoolOpt( + "retain_user_credentials", + default=CORE_DESTINATION_OPTIONS["retain_user_credentials"]["config_default"], + help=CORE_SCHEMA_PROPERTIES["retain_user_credentials"]["description"], + ), +] + +CONF = cfg.CONF +CONF.register_opts(opts, COMMON_TARGET_OPTION_GROUP) + +LOG = logging.getLogger(__name__) + + +def _requested_core_names(option_names: list | dict | None) -> set | None: + """Return the requested core option names. + + ``None``, ``{}``, and ``[]`` request every destination option. + A non-empty list requests those names. + Any other type raises before the destination provider runs. + """ + if option_names is None or option_names == {}: + return None + if not isinstance(option_names, list): + raise exception.InvalidInput( + "Invalid destination option names type: %s. Expected a list." + % type(option_names).__name__ + ) + if not option_names: + return None + return set(option_names) + + +def merge_core_destination_options( + provider_options: list, + option_names: list | dict | None = None, +) -> list: + """Merge provider dest-options onto the core list. + + Use jsonmerge arrayMergeById on name. Core is the base. The + provider list is the head. Matching rows merge field by field. + Provider-only rows are appended. Core rows always stay. + ``provider_options`` is the list returned by the provider. + ``option_names`` follows ``_requested_core_names``. + """ + requested = _requested_core_names(option_names) + core = [ + copy.deepcopy(row) + for name, row in CORE_DESTINATION_OPTIONS.items() + if requested is None or name in requested + ] + LOG.info( + "Destination options before merge: core=%s provider=%s", core, provider_options + ) + merged = _DEST_OPTIONS_MERGER.merge(core, provider_options) + LOG.info("Destination options after merge: %s", merged) + return merged + + +def _inject_core_schema_property( + object_schema: dict, name: str, schema_fragment: dict +) -> None: + if "properties" not in object_schema: + return + props = object_schema["properties"] + if name in props: + return + props[name] = copy.deepcopy(schema_fragment) + + +def inject_core_target_environment_schema(schema: dict) -> dict: + """Write core destination fields onto a provider schema. + + The provider schema is a dict. ``oneOf`` and ``anyOf`` are lists of + object schemas when present. Skip a property the provider already + declared. Skip an object that has no ``properties`` field. + """ + schema = copy.deepcopy(schema) + for name, fragment in CORE_SCHEMA_PROPERTIES.items(): + _inject_core_schema_property(schema, name, fragment) + for key in ("oneOf", "anyOf"): + for alt in schema.get(key, ()): + _inject_core_schema_property(alt, name, fragment) + return schema + + +def filter_core_option_names( + option_names: list | dict | None, +) -> list | dict | None: + """Remove injected options so destination providers do not reject them. + + ``None`` and ``{}`` request every destination option and are returned + unchanged. A list returns the same names with core names removed. + An empty result means the request named only core options. + Any other type raises. + """ + if option_names is None or option_names == {}: + return option_names + if not isinstance(option_names, list): + raise exception.InvalidInput( + "Invalid destination option names type: %s. Expected a list." + % type(option_names).__name__ + ) + return [name for name in option_names if name not in CORE_DESTINATION_OPTIONS] + + +def _current_values(spec: dict, osmorphing_info: dict, params: dict) -> dict: + if spec["target"] == "parameters": + return params + return osmorphing_info + + +def _resolve_destination_option( + spec: dict, dest_env: dict, current: dict +) -> bool | list | None: + """Return the stored value for one destination option. + + Resolved in this order: + + 1. The destination environment value when it is not None. + 2. The current stored value when the destination environment + omits the key. + 3. The common_target_options value in coriolis.conf. + + False and [] are stored. None means the option stays unset. + """ + option = spec["option"] + field = spec["field"] + if option in dest_env and dest_env[option] is not None: + return dest_env[option] + if field in current and current[field] is not None: + return current[field] + group = getattr(CONF, COMMON_TARGET_OPTION_GROUP) + return getattr(group, option, None) + + +def apply_core_destination_overrides( + osmorphing_info: dict, + target_environment: dict, +) -> dict: + """Copy destination environment options onto osmorphing info. + + ``osmorphing_info`` and ``target_environment`` are dicts. + ``osmorphing_parameters`` is a dict when the key is present. + A missing ``osmorphing_parameters`` key stays an empty dict. + Each entry in ``OSMORPHING_OVERRIDES`` uses + ``_resolve_destination_option``. + """ + if not isinstance(osmorphing_info, dict): + raise exception.InvalidInput( + "Invalid osmorphing info type: %s. Expected a dict." + % type(osmorphing_info).__name__ + ) + dest_env = target_environment + + osmorphing_info = dict(osmorphing_info) + if "osmorphing_parameters" in osmorphing_info: + raw_params = osmorphing_info["osmorphing_parameters"] + if not isinstance(raw_params, dict): + raise exception.InvalidInput( + "Invalid osmorphing parameters type: %s. Expected a dict." + % type(raw_params).__name__ + ) + params = dict(raw_params) + else: + params = {} + option_names = [spec["option"] for spec in OSMORPHING_OVERRIDES] + LOG.debug( + "Destination options before apply: dest-env=%s params=%s info=%s", + {name: dest_env[name] for name in option_names if name in dest_env}, + {name: params[name] for name in option_names if name in params}, + { + spec["field"]: osmorphing_info[spec["field"]] + for spec in OSMORPHING_OVERRIDES + if spec["target"] == "info" and spec["field"] in osmorphing_info + }, + ) + + for spec in OSMORPHING_OVERRIDES: + if spec["target"] != "parameters": + params.pop(spec["option"], None) + current = _current_values(spec, osmorphing_info, params) + value = _resolve_destination_option(spec, dest_env, current) + if value is None: + continue + current[spec["field"]] = value + LOG.info("Applying destination option '%s': %s", spec["field"], value) + + osmorphing_info["osmorphing_parameters"] = params + LOG.debug( + "Destination options after apply: params=%s info=%s", + {name: params[name] for name in option_names if name in params}, + { + spec["field"]: osmorphing_info[spec["field"]] + for spec in OSMORPHING_OVERRIDES + if spec["target"] == "info" and spec["field"] in osmorphing_info + }, + ) + return osmorphing_info diff --git a/coriolis/schemas/os_morphing_resources_schema.json b/coriolis/schemas/os_morphing_resources_schema.json index 7591a8aa0..e02855166 100644 --- a/coriolis/schemas/os_morphing_resources_schema.json +++ b/coriolis/schemas/os_morphing_resources_schema.json @@ -43,7 +43,8 @@ }, "nics_set_dhcp": { "type": "boolean", - "default": "true" + "default": true, + "description": "Sets whether or not to configure the VM to use DHCP during the OSMorphing stage. When disabled, guest static IPs will be preserved after the migration." }, "ignore_devices": { "type": "array", @@ -56,7 +57,8 @@ "properties": { "retain_user_credentials": { "type": "boolean", - "default": false + "default": false, + "description": "Prevents the destination cloud provisioning from resetting the pre-existing credentials. When disabled, the destination platform may also create a provisioned cloud guest user." } } } diff --git a/coriolis/tasks/osmorphing_tasks.py b/coriolis/tasks/osmorphing_tasks.py index b8afd8965..785240db0 100644 --- a/coriolis/tasks/osmorphing_tasks.py +++ b/coriolis/tasks/osmorphing_tasks.py @@ -5,6 +5,7 @@ from coriolis import constants, exception, schemas from coriolis.osmorphing import manager as osmorphing_manager +from coriolis.providers import conf as provider_conf from coriolis.providers import factory as providers_factory from coriolis.tasks import base @@ -150,6 +151,11 @@ def _run(self, ctxt, instance, origin, destination, task_info, event_handler): destination["type"], os_morphing_info, ) + os_morphing_info = {} + + os_morphing_info = provider_conf.apply_core_destination_overrides( + os_morphing_info, target_environment + ) return { "os_morphing_resources": os_morphing_resources, diff --git a/coriolis/tests/osmorphing/test_manager.py b/coriolis/tests/osmorphing/test_manager.py index 604e0b4f8..0490cdc47 100644 --- a/coriolis/tests/osmorphing/test_manager.py +++ b/coriolis/tests/osmorphing/test_manager.py @@ -7,6 +7,7 @@ from coriolis import constants, exception from coriolis.osmorphing import base as base_osmorphing from coriolis.osmorphing import manager +from coriolis.providers import conf as provider_conf from coriolis.tests import test_base @@ -420,3 +421,293 @@ def test_morph_image_dismount_os_exception( self._mock_user_scripts, self.event_handler, ) + + def test_apply_core_destination_overrides_rejects_non_dict(self): + self.assertRaises( + exception.InvalidInput, + provider_conf.apply_core_destination_overrides, + "not-a-dict", + {"set_dhcp": False}, + ) + self.assertRaises( + exception.InvalidInput, + provider_conf.apply_core_destination_overrides, + {"os_type": "windows", "osmorphing_parameters": "bad"}, + {"zone": "zone1"}, + ) + + def test_apply_core_destination_overrides_plugins_omitted(self): + osmorphing_info = {"os_type": "windows"} + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertTrue(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result.get("osmorphing_parameters") or {}) + self.assertFalse(result["osmorphing_parameters"]["retain_user_credentials"]) + + def test_inject_core_target_environment_schema_simple(self): + schema = { + "type": "object", + "properties": {"zone": {"type": "string"}}, + "additionalProperties": False, + } + result = provider_conf.inject_core_target_environment_schema(schema) + self.assertIn("data_transfer_mechanism", result["properties"]) + self.assertIn("set_dhcp", result["properties"]) + self.assertIn("retain_user_credentials", result["properties"]) + self.assertEqual( + ["SSH", "HTTPS"], result["properties"]["data_transfer_mechanism"]["enum"] + ) + self.assertEqual("boolean", result["properties"]["set_dhcp"]["type"]) + self.assertEqual( + "boolean", result["properties"]["retain_user_credentials"]["type"] + ) + self.assertNotIn("data_transfer_mechanism", schema["properties"]) + self.assertNotIn("set_dhcp", schema["properties"]) + self.assertNotIn("retain_user_credentials", schema["properties"]) + + def test_inject_core_target_environment_schema_keeps_provider(self): + schema = { + "type": "object", + "properties": { + "set_dhcp": { + "type": "string", + "title": "provider", + }, + }, + } + result = provider_conf.inject_core_target_environment_schema(schema) + self.assertEqual("string", result["properties"]["set_dhcp"]["type"]) + self.assertEqual("provider", result["properties"]["set_dhcp"]["title"]) + self.assertEqual("string", schema["properties"]["set_dhcp"]["type"]) + + def test_inject_core_target_environment_schema_oneof(self): + schema = { + "oneOf": [ + {"properties": {"migr_network": {"type": "string"}}}, + {"properties": {"network_map": {"type": "object"}}}, + ] + } + result = provider_conf.inject_core_target_environment_schema(schema) + for alt in result["oneOf"]: + self.assertIn("data_transfer_mechanism", alt["properties"]) + self.assertIn("set_dhcp", alt["properties"]) + self.assertIn("retain_user_credentials", alt["properties"]) + + def _row_by_name(self, options, name): + for opt in options: + if opt.get("name") == name: + return opt + self.fail("Missing destination option %s" % name) + + def test_merge_core_destination_options_appends(self): + options = [{"name": "zone", "values": []}] + result = provider_conf.merge_core_destination_options(options) + names = [opt["name"] for opt in result] + self.assertIn("data_transfer_mechanism", names) + self.assertIn("set_dhcp", names) + self.assertIn("retain_user_credentials", names) + self.assertEqual(options, [{"name": "zone", "values": []}]) + + def test_merge_core_destination_options_provider_overwrites(self): + provider_options = [ + { + "name": "cloudbase_init_plugins", + "values": ["already-set"], + } + ] + merged = provider_conf.merge_core_destination_options(provider_options) + self.assertEqual( + ["already-set"], + self._row_by_name(merged, "cloudbase_init_plugins")["values"], + ) + self.assertEqual( + "HTTPS", + self._row_by_name(merged, "data_transfer_mechanism")["config_default"], + ) + self.assertEqual(True, self._row_by_name(merged, "set_dhcp")["config_default"]) + self.assertEqual( + False, + self._row_by_name(merged, "retain_user_credentials")["config_default"], + ) + + def test_merge_core_destination_options_nested_row_fields(self): + provider_options = [ + { + "name": "set_dhcp", + "config_default": False, + } + ] + merged = provider_conf.merge_core_destination_options(provider_options) + row = self._row_by_name(merged, "set_dhcp") + self.assertFalse(row["config_default"]) + self.assertEqual([], row["values"]) + + def test_merge_dest_opts_list_respects_names(self): + options = [{"name": "zone", "values": []}] + result = provider_conf.merge_core_destination_options( + options, option_names=["zone"] + ) + self.assertEqual(options, result) + + def test_merge_dest_opts_list_empty_dict(self): + options = [{"name": "zone", "values": []}] + result = provider_conf.merge_core_destination_options(options, option_names={}) + names = [opt["name"] for opt in result] + self.assertIn("data_transfer_mechanism", names) + self.assertIn("set_dhcp", names) + self.assertIn("retain_user_credentials", names) + + def test_merge_core_names_only(self): + result = provider_conf.merge_core_destination_options( + [], + option_names=["set_dhcp", "retain_user_credentials"], + ) + self.assertEqual( + ["set_dhcp", "retain_user_credentials"], + [opt["name"] for opt in result], + ) + + def test_filter_core_option_names(self): + result = provider_conf.filter_core_option_names( + [ + "zone", + "data_transfer_mechanism", + "set_dhcp", + "retain_user_credentials", + "import_node", + ] + ) + self.assertEqual(["zone", "import_node"], result) + self.assertEqual({}, provider_conf.filter_core_option_names({})) + self.assertIsNone(provider_conf.filter_core_option_names(None)) + self.assertRaises( + exception.InvalidInput, + provider_conf.filter_core_option_names, + "zone", + ) + + def test_get_data_transfer_mechanism_destination_option(self): + result = provider_conf.CORE_DESTINATION_OPTIONS["data_transfer_mechanism"] + self.assertEqual("data_transfer_mechanism", result["name"]) + self.assertEqual(["SSH", "HTTPS"], result["values"]) + self.assertEqual("HTTPS", result["config_default"]) + + def test_apply_core_destination_overrides_set_dhcp(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {}, + } + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"set_dhcp": False} + ) + self.assertFalse(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result.get("osmorphing_parameters") or {}) + self.assertNotIn("nics_set_dhcp", osmorphing_info) + + def test_apply_core_destination_overrides_set_dhcp_conf_fallback(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {}, + } + provider_conf.CONF.set_override( + "set_dhcp", + False, + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + provider_conf.CONF.clear_override, + "set_dhcp", + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertFalse(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result.get("osmorphing_parameters") or {}) + + def test_apply_core_destination_overrides_set_dhcp_keeps_provider(self): + osmorphing_info = { + "os_type": "linux", + "nics_set_dhcp": True, + } + provider_conf.CONF.set_override( + "set_dhcp", + False, + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + provider_conf.CONF.clear_override, + "set_dhcp", + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertTrue(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result.get("osmorphing_parameters") or {}) + + def test_apply_core_destination_overrides_set_dhcp_from_dest_env(self): + provider_conf.CONF.set_override( + "set_dhcp", + True, + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + provider_conf.CONF.clear_override, + "set_dhcp", + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = provider_conf.apply_core_destination_overrides( + { + "os_type": "linux", + "nics_set_dhcp": True, + }, + {"set_dhcp": False}, + ) + self.assertFalse(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result.get("osmorphing_parameters") or {}) + + def test_apply_core_destination_overrides_retain_user_credentials(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {}, + } + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"retain_user_credentials": True} + ) + self.assertTrue(result["osmorphing_parameters"]["retain_user_credentials"]) + + def test_apply_core_destination_overrides_retain_user_credentials_keeps_provider( + self, + ): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {"retain_user_credentials": True}, + } + provider_conf.CONF.set_override( + "retain_user_credentials", + False, + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + provider_conf.CONF.clear_override, + "retain_user_credentials", + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertTrue(result["osmorphing_parameters"]["retain_user_credentials"]) + + def test_apply_core_destination_overrides_retain_user_credentials_from_dest_env( + self, + ): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {"retain_user_credentials": True}, + } + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"retain_user_credentials": False} + ) + self.assertFalse(result["osmorphing_parameters"]["retain_user_credentials"]) diff --git a/coriolis/tests/tasks/test_osmorphing_tasks.py b/coriolis/tests/tasks/test_osmorphing_tasks.py index 3b71f714a..e97a305cc 100644 --- a/coriolis/tests/tasks/test_osmorphing_tasks.py +++ b/coriolis/tests/tasks/test_osmorphing_tasks.py @@ -6,6 +6,7 @@ import ddt from coriolis import constants, exception, schemas +from coriolis.providers import conf as provider_conf from coriolis.tasks import osmorphing_tasks from coriolis.tests import test_base @@ -98,7 +99,10 @@ def test__run( prov_fun = mock_get_provider.return_value.deploy_os_morphing_resources prov_fun.return_value = import_info destination = mock.MagicMock() - task_info = mock.MagicMock() + task_info = { + "target_environment": {}, + "instance_deployment_info": {}, + } method_args = [ mock.sentinel.ctxt, mock.sentinel.instance, @@ -126,7 +130,10 @@ def _get_result(*args): expected_result = { "os_morphing_resources": import_info.get('os_morphing_resources'), "osmorphing_connection_info": (mock_marshal_conn_info.return_value), - "osmorphing_info": import_info.get('osmorphing_info', {}), + "osmorphing_info": provider_conf.apply_core_destination_overrides( + dict(import_info.get('osmorphing_info') or {}), + {}, + ), } self.assertEqual(expected_result, _get_result(*method_args)) mock_get_provider.assert_called_once_with( diff --git a/coriolis/tests/worker/rpc/test_server.py b/coriolis/tests/worker/rpc/test_server.py index 292c8a636..0a123427d 100644 --- a/coriolis/tests/worker/rpc/test_server.py +++ b/coriolis/tests/worker/rpc/test_server.py @@ -14,6 +14,7 @@ from coriolis.conductor.rpc import client as conductor_client from coriolis.conductor.rpc import utils as cond_rpc_utils from coriolis.minion_manager.rpc import client as minion_client +from coriolis.providers import conf as provider_conf from coriolis.providers import factory as providers_factory from coriolis.tasks import factory as task_runners_factory from coriolis.tests import test_base @@ -638,16 +639,24 @@ def test_get_endpoint_instance( def test_get_endpoint_destination_options( self, mock_get_provider, mock_get_secret, mock_validate ): + provider_options = [{"name": "zone", "values": []}] + mock_get_provider.return_value.get_target_environment_options.return_value = ( + provider_options + ) + def call_get_endpoint_destination_options(): return self.server.get_endpoint_destination_options( mock.sentinel.context, mock.sentinel.platform_name, mock.sentinel.connection_info, mock.sentinel.environment, - mock.sentinel.option_names, + {}, ) options = call_get_endpoint_destination_options() + expected = provider_conf.merge_core_destination_options( + provider_options, option_names={} + ) mock_get_provider.assert_called_once_with( mock.sentinel.platform_name, @@ -662,22 +671,48 @@ def call_get_endpoint_destination_options(): mock.sentinel.context, mock_get_secret.return_value, env=mock.sentinel.environment, - option_names=mock.sentinel.option_names, + option_names={}, ) mock_validate.assert_called_once_with( - mock_get_provider.return_value.get_target_environment_options.return_value, + expected, schemas.CORIOLIS_DESTINATION_ENVIRONMENT_OPTIONS_SCHEMA, ) - self.assertEqual( - options, - mock_get_provider.return_value.get_target_environment_options.return_value, - ) + self.assertEqual(options, expected) # if the provider is not found, raise InvalidInput mock_get_provider.return_value = None self.assertRaises(exception.InvalidInput, call_get_endpoint_destination_options) + @mock.patch.object(schemas, "validate_value") + @mock.patch.object(utils, "get_secret_connection_info") + @mock.patch.object(providers_factory, "get_provider") + def test_get_endpoint_destination_options_core_names_only( + self, mock_get_provider, mock_get_secret, mock_validate + ): + option_names = ["set_dhcp", "retain_user_credentials"] + options = self.server.get_endpoint_destination_options( + mock.sentinel.context, + mock.sentinel.platform_name, + mock.sentinel.connection_info, + mock.sentinel.environment, + option_names, + ) + expected = provider_conf.merge_core_destination_options( + [], option_names=option_names + ) + + mock_get_secret.assert_not_called() + mock_get_provider.return_value.get_target_environment_options.assert_not_called() + mock_validate.assert_called_once_with( + expected, + schemas.CORIOLIS_DESTINATION_ENVIRONMENT_OPTIONS_SCHEMA, + ) + self.assertEqual( + ["set_dhcp", "retain_user_credentials"], + [opt["name"] for opt in options], + ) + @mock.patch.object(schemas, "validate_value") @mock.patch.object(utils, "get_secret_connection_info") @mock.patch.object(providers_factory, "get_provider") @@ -1008,6 +1043,10 @@ def test_validate_endpoint_source_environment( def test_validate_endpoint_target_environment( self, mock_get_provider, mock_validate ): + schema = {"type": "object", "properties": {"zone": {"type": "string"}}} + mock_get_provider.return_value.get_target_environment_schema.return_value = ( + schema + ) result = self.server.validate_endpoint_target_environment( mock.sentinel.context, mock.sentinel.target_platform_name, @@ -1021,7 +1060,7 @@ def test_validate_endpoint_target_environment( ) mock_validate.assert_called_once_with( mock.sentinel.target_environment, - mock_get_provider.return_value.get_target_environment_schema.return_value, + provider_conf.inject_core_target_environment_schema(schema), ) self.assertEqual(result, (True, None)) @@ -1167,6 +1206,10 @@ def call_validate_endpoint_connection(): ), ) def test_get_provider_schemas(self, schema_type, mock_get_provider): + mock_get_provider.return_value.get_target_environment_schema.return_value = { + "type": "object", + "properties": {}, + } provider_schemas = self.server.get_provider_schemas( mock.sentinel.context, mock.sentinel.platform_name, schema_type[0] ) diff --git a/coriolis/worker/rpc/server.py b/coriolis/worker/rpc/server.py index a7e0230ee..a47ecdd43 100644 --- a/coriolis/worker/rpc/server.py +++ b/coriolis/worker/rpc/server.py @@ -19,6 +19,7 @@ from coriolis.conductor.rpc import client as rpc_conductor_client from coriolis.conductor.rpc import utils as conductor_rpc_utils from coriolis.minion_manager.rpc import client as rpc_minion_manager_client +from coriolis.providers import conf as provider_conf from coriolis.providers import factory as providers_factory from coriolis.tasks import factory as task_runners_factory @@ -456,10 +457,27 @@ def get_endpoint_destination_options( "destination environment options." % platform_name ) - secret_connection_info = utils.get_secret_connection_info(ctxt, connection_info) - - options = provider.get_target_environment_options( - ctxt, secret_connection_info, env=env, option_names=option_names + provider_option_names = provider_conf.filter_core_option_names(option_names) + # Providers treat [] as every option. A request that names only + # core options must not be sent that way. + if ( + isinstance(option_names, list) + and option_names + and provider_option_names == [] + ): + options = [] + else: + secret_connection_info = utils.get_secret_connection_info( + ctxt, connection_info + ) + options = provider.get_target_environment_options( + ctxt, + secret_connection_info, + env=env, + option_names=provider_option_names, + ) + options = provider_conf.merge_core_destination_options( + options, option_names=option_names ) schemas.validate_value( @@ -604,6 +622,9 @@ def validate_endpoint_target_environment(self, ctxt, platform_name, target_env): platform_name, constants.PROVIDER_TYPE_OS_MORPHING, None ) target_env_schema = provider.get_target_environment_schema() + target_env_schema = provider_conf.inject_core_target_environment_schema( + target_env_schema + ) is_valid = True message = None @@ -718,6 +739,7 @@ def get_provider_schemas(self, ctxt, platform_name, provider_type): if provider_type == constants.PROVIDER_TYPE_TRANSFER_IMPORT: schema = provider.get_target_environment_schema() + schema = provider_conf.inject_core_target_environment_schema(schema) schemas["destination_environment_schema"] = schema if provider_type == constants.PROVIDER_TYPE_TRANSFER_EXPORT: diff --git a/etc/coriolis/coriolis.conf b/etc/coriolis/coriolis.conf index 5d172f54d..23c5567f6 100644 --- a/etc/coriolis/coriolis.conf +++ b/etc/coriolis/coriolis.conf @@ -3,6 +3,20 @@ log_dir=/tmp log_file=coriolis.log messaging_transport_url=rabbit://coriolis:Passw0rd@127.0.0.1:5672/ +[common_target_options] +# Configure guest NICs with DHCP during OS morphing. +# Written to osmorphing_info nics_set_dhcp. +# Used when the destination environment omits set_dhcp and the provider omits nics_set_dhcp. +# When disabled, guest static IPs are preserved after the migration. +# Default is true. +# set_dhcp = true +# Prevents the destination cloud provisioning from resetting the pre-existing credentials. +# When disabled, the destination platform may also create a provisioned cloud guest user. +# Stored on osmorphing_parameters retain_user_credentials. +# Used when the destination environment and the provider both omit the key. +# Default is false. +# retain_user_credentials = false + [keystone_authtoken] auth_type = password auth_url = http://keystone:35357 diff --git a/requirements.txt b/requirements.txt index 89d38ee77..b321dbbe6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ keystoneauth1 keystonemiddleware Jinja2 jsonschema +jsonmerge # NOTE (aznashwan, 21-03-31): kombu>=5 has some weird interactions with # oslo_messaging which causes extreme RAM usage in the API service, # so we limit its version here.