diff --git a/coriolis/osmorphing/conf.py b/coriolis/osmorphing/conf.py new file mode 100644 index 000000000..068d2d5d9 --- /dev/null +++ b/coriolis/osmorphing/conf.py @@ -0,0 +1,301 @@ +# 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_option group. +""" + +import copy + +from jsonmerge import Merger +from oslo_config import cfg +from oslo_log import log as logging + +from coriolis.osmorphing import windows + +COMMON_TARGET_OPTION_GROUP = "common_target_option" + +CORE_SCHEMA_PROPERTIES = { + "cloudbase_init_plugins": { + "type": "array", + "items": {"type": "string"}, + "title": "Cloudbase-Init Plugins", + "description": ( + "Cloudbase-Init plugin class names for Windows guests. " + "When set, this list is written into the guest. " + "When omitted, coriolis.conf is used, " + "then the provider plugin list, " + "then the built-in list in Coriolis Windows OS morphing." + ), + }, + "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 = { + "cloudbase_init_plugins": windows.CLOUDBASE_INIT_PLUGINS_DESTINATION_OPTION, + "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": "cloudbase_init_plugins", + "field": "cloudbase_init_plugins", + "target": "parameters", + }, + { + "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.ListOpt( + "cloudbase_init_plugins", + default=None, + help=CORE_SCHEMA_PROPERTIES["cloudbase_init_plugins"]["description"], + ), + 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 merge_core_destination_options( + provider_options: list | tuple | None, + option_names: list | tuple | set | 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. + Treat ``None`` and ``{}`` as a request for all destination options. + Skip a core option when a non-empty name list does not include it. + """ + if isinstance(option_names, (list, tuple, set)): + requested = set(option_names) if option_names else None + else: + requested = None + core = [ + copy.deepcopy(row) + for name, row in CORE_DESTINATION_OPTIONS.items() + if requested is None or name in requested + ] + if not isinstance(provider_options, (list, tuple)): + provider_options = [] + provider_options = list(provider_options) + 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: + props = object_schema.get("properties") + if not isinstance(props, dict): + return + if name in props: + return + props[name] = copy.deepcopy(schema_fragment) + + +def inject_core_target_environment_schema(schema: dict | None) -> dict | None: + """Write core destination fields onto a provider schema. + + Skip a property when the provider already declared it. + """ + if not isinstance(schema, dict): + return schema + 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) or []: + if isinstance(alt, dict): + _inject_core_schema_property(alt, name, fragment) + return schema + + +def filter_core_option_names( + option_names: list | tuple | set | None, +) -> list | tuple | set | None: + """Remove injected options so destination providers do not reject them.""" + if not isinstance(option_names, (list, tuple, set)): + return option_names + 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_option 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 | None, + target_environment: dict | None, +) -> dict: + """Copy destination environment options onto osmorphing info. + + Each entry in ``OSMORPHING_OVERRIDES`` uses + ``_resolve_destination_option``. + """ + if not isinstance(osmorphing_info, dict): + osmorphing_info = osmorphing_info or {} + if isinstance(target_environment, dict): + dest_env = target_environment + else: + dest_env = {} + + osmorphing_info = dict(osmorphing_info) + params = dict(osmorphing_info.get("osmorphing_parameters") or {}) + option_names = [spec["option"] for spec in OSMORPHING_OVERRIDES] + LOG.info( + "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.info( + "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/osmorphing/windows.py b/coriolis/osmorphing/windows.py index bd34fc277..0752fcd01 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -45,6 +45,19 @@ 'cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin', ] +CLOUDBASE_INIT_PLUGINS_DESTINATION_OPTION = { + "name": "cloudbase_init_plugins", + "values": [ + { + "id": item, + "name": item.rsplit(".", 1)[-1], + } + for item in CLOUDBASE_INIT_DEFAULT_PLUGINS + ], + "config_default": list(CLOUDBASE_INIT_DEFAULT_PLUGINS), +} + + CLOUDBASE_INIT_DEFAULT_METADATA_SVCS = [ 'cloudbaseinit.metadata.services.httpservice.HttpService', 'cloudbaseinit.metadata.services.configdrive.ConfigDriveService', @@ -531,6 +544,60 @@ def _write_local_script(self, base_dir, script_path, priority=50): remote_script_path, ) + def _parse_cloudbase_init_plugins(self, plugins): + """Return the plugin list, or None if unset. + + None means the caller left the value unset. + Any other value must be a list. + """ + if plugins is None: + return None + if not isinstance(plugins, list): + raise exception.CoriolisException( + "Invalid plugins parameter. Must be list." + ) + return plugins + + def _resolve_cloudbase_init_plugins(self, plugins=None): + """Return the Cloudbase-Init plugin list to write into the guest. + + Resolved in this order: + + 1. osmorphing_parameters ``cloudbase_init_plugins`` + 2. coriolis.conf ``[common_target_option] cloudbase_init_plugins``. + ``apply_core_destination_overrides`` stores this list before + the provider argument is read. + 3. provider-supplied ``plugins`` argument + 4. ``CLOUDBASE_INIT_DEFAULT_PLUGINS`` + """ + param_plugins = self._parse_cloudbase_init_plugins( + self._osmorphing_parameters.get("cloudbase_init_plugins") + ) + if param_plugins is not None: + LOG.info( + "Using Cloudbase-Init plugins from OS morphing parameters: %s", + param_plugins, + ) + return param_plugins + + provider_plugins = self._parse_cloudbase_init_plugins(plugins) + if provider_plugins is not None: + return provider_plugins + + from coriolis.osmorphing import conf as osmorphing_conf + + conf_plugins = self._parse_cloudbase_init_plugins( + osmorphing_conf.CONF.common_target_option.cloudbase_init_plugins + ) + if conf_plugins is not None: + LOG.info( + "Using Cloudbase-Init plugins from coriolis.conf: %s", + conf_plugins, + ) + return conf_plugins + + return list(CLOUDBASE_INIT_DEFAULT_PLUGINS) + def _write_cloudbase_init_conf( self, cloudbaseinit_base_dir, @@ -543,12 +610,7 @@ def _write_cloudbase_init_conf( if metadata_services is None: metadata_services = CLOUDBASE_INIT_DEFAULT_METADATA_SVCS - if plugins is None: - plugins = CLOUDBASE_INIT_DEFAULT_PLUGINS - elif type(plugins) is not list: - raise exception.CoriolisException( - "Invalid plugins parameter. Must be list." - ) + plugins = self._resolve_cloudbase_init_plugins(plugins) LOG.info("Writing Cloudbase-Init configuration files") conf_dir = "%s\\conf" % cloudbaseinit_base_dir diff --git a/coriolis/schemas/os_morphing_resources_schema.json b/coriolis/schemas/os_morphing_resources_schema.json index 7591a8aa0..60308252b 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,15 @@ "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." + }, + "cloudbase_init_plugins": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cloudbase-Init plugin class names for Windows guests. When set, this list is written into the guest. When omitted, coriolis.conf is used, then the provider plugin list, then the built-in list in Coriolis Windows OS morphing." } } } diff --git a/coriolis/tasks/osmorphing_tasks.py b/coriolis/tasks/osmorphing_tasks.py index b8afd8965..3d3c98ebd 100644 --- a/coriolis/tasks/osmorphing_tasks.py +++ b/coriolis/tasks/osmorphing_tasks.py @@ -4,6 +4,7 @@ from oslo_log import log as logging from coriolis import constants, exception, schemas +from coriolis.osmorphing import conf as osmorphing_conf from coriolis.osmorphing import manager as osmorphing_manager from coriolis.providers import factory as providers_factory from coriolis.tasks import base @@ -151,6 +152,10 @@ def _run(self, ctxt, instance, origin, destination, task_info, event_handler): os_morphing_info, ) + os_morphing_info = osmorphing_conf.apply_core_destination_overrides( + os_morphing_info, target_environment + ) + return { "os_morphing_resources": os_morphing_resources, "osmorphing_connection_info": osmorphing_connection_info, diff --git a/coriolis/tests/osmorphing/test_manager.py b/coriolis/tests/osmorphing/test_manager.py index 604e0b4f8..71e88e01b 100644 --- a/coriolis/tests/osmorphing/test_manager.py +++ b/coriolis/tests/osmorphing/test_manager.py @@ -6,6 +6,7 @@ from coriolis import constants, exception from coriolis.osmorphing import base as base_osmorphing +from coriolis.osmorphing import conf as dest_opts from coriolis.osmorphing import manager from coriolis.tests import test_base @@ -420,3 +421,334 @@ def test_morph_image_dismount_os_exception( self._mock_user_scripts, self.event_handler, ) + + def test_apply_core_destination_overrides_plugins_from_target_env(self): + osmorphing_info = { + "os_type": "windows", + "nics_set_dhcp": True, + } + target_environment = { + "cloudbase_init_plugins": ["cloudbaseinit.plugins.common.mtu.MTUPlugin"] + } + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, target_environment + ) + self.assertEqual( + result["osmorphing_parameters"]["cloudbase_init_plugins"], + target_environment["cloudbase_init_plugins"], + ) + self.assertTrue(result["nics_set_dhcp"]) + self.assertNotIn("set_dhcp", result["osmorphing_parameters"]) + self.assertNotIn( + "cloudbase_init_plugins", osmorphing_info.get("osmorphing_parameters") or {} + ) + + def test_apply_core_destination_overrides_plugins_empty_list(self): + osmorphing_info = {"os_type": "windows"} + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"cloudbase_init_plugins": []} + ) + self.assertEqual(result["osmorphing_parameters"]["cloudbase_init_plugins"], []) + + def test_apply_core_destination_overrides_plugins_keeps_provider(self): + plugins = ["cloudbaseinit.plugins.common.mtu.MTUPlugin"] + osmorphing_info = { + "os_type": "windows", + "osmorphing_parameters": {"cloudbase_init_plugins": plugins}, + } + dest_opts.CONF.set_override( + "cloudbase_init_plugins", + ["other.Plugin"], + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + dest_opts.CONF.clear_override, + "cloudbase_init_plugins", + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertEqual( + result["osmorphing_parameters"]["cloudbase_init_plugins"], plugins + ) + + def test_apply_core_destination_overrides_plugins_omitted(self): + osmorphing_info = {"os_type": "windows"} + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertNotIn( + "cloudbase_init_plugins", result.get("osmorphing_parameters") or {} + ) + 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 = dest_opts.inject_core_target_environment_schema(schema) + self.assertIn("cloudbase_init_plugins", result["properties"]) + 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("cloudbase_init_plugins", schema["properties"]) + 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 = dest_opts.inject_core_target_environment_schema(schema) + self.assertEqual("string", result["properties"]["set_dhcp"]["type"]) + self.assertEqual("provider", result["properties"]["set_dhcp"]["title"]) + self.assertIn("cloudbase_init_plugins", result["properties"]) + 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 = dest_opts.inject_core_target_environment_schema(schema) + for alt in result["oneOf"]: + self.assertIn("cloudbase_init_plugins", alt["properties"]) + 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 = dest_opts.merge_core_destination_options(options) + names = [opt["name"] for opt in result] + self.assertIn("cloudbase_init_plugins", names) + 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 = dest_opts.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 = dest_opts.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 = dest_opts.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 = dest_opts.merge_core_destination_options(options, option_names={}) + names = [opt["name"] for opt in result] + self.assertIn("cloudbase_init_plugins", names) + self.assertIn("data_transfer_mechanism", names) + self.assertIn("set_dhcp", names) + self.assertIn("retain_user_credentials", names) + + def test_filter_core_option_names(self): + result = dest_opts.filter_core_option_names( + [ + "zone", + "cloudbase_init_plugins", + "data_transfer_mechanism", + "set_dhcp", + "retain_user_credentials", + "import_node", + ] + ) + self.assertEqual(["zone", "import_node"], result) + self.assertEqual({}, dest_opts.filter_core_option_names({})) + + def test_get_data_transfer_mechanism_destination_option(self): + result = dest_opts.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_get_cloudbase_init_plugins_destination_option(self): + result = dest_opts.CORE_DESTINATION_OPTIONS["cloudbase_init_plugins"] + self.assertEqual("cloudbase_init_plugins", result["name"]) + self.assertTrue(result["values"]) + self.assertIn("id", result["values"][0]) + self.assertIn("name", result["values"][0]) + self.assertEqual( + [item["id"] for item in result["values"]], result["config_default"] + ) + + def test_apply_core_destination_overrides_set_dhcp(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {}, + } + result = dest_opts.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": {}, + } + dest_opts.CONF.set_override( + "set_dhcp", + False, + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + dest_opts.CONF.clear_override, + "set_dhcp", + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + result = dest_opts.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, + } + dest_opts.CONF.set_override( + "set_dhcp", + False, + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + dest_opts.CONF.clear_override, + "set_dhcp", + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + result = dest_opts.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): + dest_opts.CONF.set_override( + "set_dhcp", + True, + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + dest_opts.CONF.clear_override, + "set_dhcp", + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + result = dest_opts.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 = dest_opts.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}, + } + dest_opts.CONF.set_override( + "retain_user_credentials", + False, + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + dest_opts.CONF.clear_override, + "retain_user_credentials", + group=dest_opts.COMMON_TARGET_OPTION_GROUP, + ) + result = dest_opts.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 = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"retain_user_credentials": False} + ) + self.assertFalse(result["osmorphing_parameters"]["retain_user_credentials"]) diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index 220eff560..d82848c53 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -10,6 +10,7 @@ import ddt from coriolis import exception +from coriolis.osmorphing import conf as osmorphing_conf from coriolis.osmorphing import windows from coriolis.tests import test_base @@ -592,12 +593,95 @@ def test__write_cloudbase_init_conf( 'C:\\Cloudbase-Init', mocked_full_path, priority=10 ) + def test__resolve_cloudbase_init_plugins_provider_default(self): + provider_plugins = ['cloudbaseinit.plugins.common.mtu.MTUPlugin'] + result = self.morphing_tools._resolve_cloudbase_init_plugins(provider_plugins) + self.assertEqual(result, provider_plugins) + + def test_get_cloudbase_init_plugins_destination_option(self): + result = windows.CLOUDBASE_INIT_PLUGINS_DESTINATION_OPTION + self.assertEqual("cloudbase_init_plugins", result["name"]) + self.assertTrue(result["values"]) + self.assertEqual( + result["values"][0]["id"].rsplit(".", 1)[-1], result["values"][0]["name"] + ) + self.assertEqual( + [item["id"] for item in result["values"]], result["config_default"] + ) + + def test__resolve_cloudbase_init_plugins_core_default(self): + result = self.morphing_tools._resolve_cloudbase_init_plugins() + self.assertEqual(result, windows.CLOUDBASE_INIT_DEFAULT_PLUGINS) + + def test__resolve_cloudbase_init_plugins_provider_beats_conf(self): + conf_plugins = ['cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin'] + provider_plugins = ['cloudbaseinit.plugins.common.mtu.MTUPlugin'] + osmorphing_conf.CONF.set_override( + 'cloudbase_init_plugins', + conf_plugins, + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + osmorphing_conf.CONF.clear_override, + 'cloudbase_init_plugins', + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = self.morphing_tools._resolve_cloudbase_init_plugins(provider_plugins) + self.assertEqual(result, provider_plugins) + + def test__resolve_cloudbase_init_plugins_conf_override(self): + conf_plugins = ['cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin'] + osmorphing_conf.CONF.set_override( + 'cloudbase_init_plugins', + conf_plugins, + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + osmorphing_conf.CONF.clear_override, + 'cloudbase_init_plugins', + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = self.morphing_tools._resolve_cloudbase_init_plugins() + self.assertEqual(result, conf_plugins) + + def test__resolve_cloudbase_init_plugins_api_override(self): + api_plugins = ['cloudbaseinit.plugins.common.userdata.UserDataPlugin'] + osmorphing_conf.CONF.set_override( + 'cloudbase_init_plugins', + ['cloudbaseinit.plugins.common.mtu.MTUPlugin'], + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + osmorphing_conf.CONF.clear_override, + 'cloudbase_init_plugins', + group=osmorphing_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.morphing_tools._osmorphing_parameters = { + "cloudbase_init_plugins": api_plugins + } + result = self.morphing_tools._resolve_cloudbase_init_plugins( + ['ignored.provider.Plugin'] + ) + self.assertEqual(result, api_plugins) + + def test__resolve_cloudbase_init_plugins_rejects_string(self): + self.morphing_tools._osmorphing_parameters = { + "cloudbase_init_plugins": ( + "cloudbaseinit.plugins.common.mtu.MTUPlugin, " + "cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin" + ) + } + self.assertRaises( + exception.CoriolisException, + self.morphing_tools._resolve_cloudbase_init_plugins, + ) + @mock.patch.object(windows.utils, 'write_winrm_file') @mock.patch.object(windows.BaseWindowsMorphingTools, '_write_local_script') def test__write_cloudbase_init_conf_with_exception( self, mock_write_local_script, mock_write_winrm_file ): - plugins = "invalid plugins" + plugins = {"invalid": "plugins"} self.assertRaises( exception.CoriolisException, diff --git a/coriolis/tests/tasks/test_osmorphing_tasks.py b/coriolis/tests/tasks/test_osmorphing_tasks.py index 3b71f714a..afd9cd0c0 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.osmorphing import conf as dest_opts from coriolis.tasks import osmorphing_tasks from coriolis.tests import test_base @@ -126,7 +127,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": dest_opts.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( @@ -150,6 +154,58 @@ def _get_result(*args): import_info.get('osmorphing_connection_info') ) + @mock.patch('coriolis.providers.factory.get_provider') + @mock.patch('coriolis.tasks.base.get_connection_info') + @mock.patch('coriolis.schemas.validate_value') + @mock.patch('coriolis.tasks.base.marshal_migr_conn_info') + def test__run_applies_cloudbase_init_plugins_from_target_environment( + self, + mock_marshal_conn_info, + mock_validate_value, + mock_get_conn_info, + mock_get_provider, + ): + plugins = ['cloudbaseinit.plugins.common.mtu.MTUPlugin'] + import_info = { + "os_morphing_resources": {"res1": "id1"}, + "osmorphing_connection_info": {"info1": "secret1"}, + "osmorphing_info": { + "os_type": "windows", + "nics_set_dhcp": True, + }, + } + prov_fun = mock_get_provider.return_value.deploy_os_morphing_resources + prov_fun.return_value = import_info + task_info = { + "target_environment": { + "cloudbase_init_plugins": plugins, + "set_dhcp": False, + }, + "instance_deployment_info": {}, + } + destination = mock.MagicMock() + + result = self.task_runner._run( + mock.sentinel.ctxt, + mock.sentinel.instance, + mock.sentinel.origin, + destination, + task_info, + mock.sentinel.event_handler, + ) + + self.assertEqual( + result["osmorphing_info"]["osmorphing_parameters"][ + "cloudbase_init_plugins" + ], + plugins, + ) + self.assertFalse(result["osmorphing_info"]["nics_set_dhcp"]) + self.assertNotIn( + "set_dhcp", + result["osmorphing_info"].get("osmorphing_parameters") or {}, + ) + class DeleteOSMorphingResourcesTaskTestCase(test_base.CoriolisBaseTestCase): def setUp(self): diff --git a/coriolis/worker/rpc/server.py b/coriolis/worker/rpc/server.py index a7e0230ee..bbbd33d34 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.osmorphing import conf as osmorphing_conf from coriolis.providers import factory as providers_factory from coriolis.tasks import factory as task_runners_factory @@ -458,10 +459,19 @@ def get_endpoint_destination_options( secret_connection_info = utils.get_secret_connection_info(ctxt, connection_info) + provider_option_names = osmorphing_conf.filter_core_option_names(option_names) options = provider.get_target_environment_options( - ctxt, secret_connection_info, env=env, option_names=option_names + ctxt, + secret_connection_info, + env=env, + option_names=provider_option_names, ) + if isinstance(options, (list, tuple)): + options = osmorphing_conf.merge_core_destination_options( + options, option_names=option_names + ) + schemas.validate_value( options, schemas.CORIOLIS_DESTINATION_ENVIRONMENT_OPTIONS_SCHEMA ) @@ -604,6 +614,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 = osmorphing_conf.inject_core_target_environment_schema( + target_env_schema + ) is_valid = True message = None @@ -718,6 +731,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 = osmorphing_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..b96f8d587 100644 --- a/etc/coriolis/coriolis.conf +++ b/etc/coriolis/coriolis.conf @@ -3,6 +3,23 @@ log_dir=/tmp log_file=coriolis.log messaging_transport_url=rabbit://coriolis:Passw0rd@127.0.0.1:5672/ +[common_target_option] +# Comma-separated Cloudbase-Init plugin class names for Windows OS morphing. +# Used when the destination environment and the provider both omit the key. +# cloudbase_init_plugins = cloudbaseinit.plugins.common.mtu.MTUPlugin,cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin +# 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.