diff --git a/coriolis/osmorphing/conf.py b/coriolis/osmorphing/conf.py new file mode 100644 index 000000000..6a6f8b256 --- /dev/null +++ b/coriolis/osmorphing/conf.py @@ -0,0 +1,215 @@ +# 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. +""" + +import copy + +from jsonmerge import Merger +from oslo_config import cfg +from oslo_log import log as logging + +from coriolis.osmorphing import windows + +CORE_SCHEMA_PROPERTIES = { + "cloudbase_init_plugins": { + "type": "array", + "items": {"type": "string"}, + "title": "Cloudbase-Init Plugins", + "description": ( + "Cloudbase-Init plugins to run in Windows guests after 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." + ), + }, +} + +CORE_DESTINATION_OPTIONS = ( + windows.CLOUDBASE_INIT_PLUGINS_DESTINATION_OPTION, + { + "name": "data_transfer_mechanism", + "values": ["SSH", "HTTPS"], + "config_default": "HTTPS", + }, + { + "name": "set_dhcp", + "values": [], + "config_default": True, + }, +) + +CORE_OPTION_NAMES = {row["name"] for row in CORE_DESTINATION_OPTIONS} +OSMORPHING_OPTION_NAMES = frozenset( + ( + "cloudbase_init_plugins", + "set_dhcp", + ) +) + +_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.StrOpt( + "data_transfer_mechanism", + default="HTTPS", + choices=["SSH", "HTTPS"], + help=CORE_SCHEMA_PROPERTIES["data_transfer_mechanism"]["description"], + ), + cfg.BoolOpt( + "set_dhcp", default=True, help=CORE_SCHEMA_PROPERTIES["set_dhcp"]["description"] + ), +] + +CONF = cfg.CONF +CONF.register_opts(opts) + +LOG = logging.getLogger(__name__) + + +def merge_core_destination_options(provider_options, option_names=None): + """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 row in CORE_DESTINATION_OPTIONS + if requested is None or row["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, name, schema_fragment): + 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): + """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): + """Remove injected options so destination providers do not reject them.""" + if not isinstance(option_names, (list, tuple, set)): + return option_names + names = CORE_OPTION_NAMES + return [name for name in option_names if name not in names] + + +def apply_core_destination_overrides(osmorphing_info, target_environment): + """Copy dest-env morphing options onto osmorphing_parameters. + + Dest-env wins when the key is present, including False and []. + Do not replace provider-written values when dest-env omits the key. + Do not copy non-osmorphing params. + """ + 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 {}) + LOG.info( + "Destination options before apply: dest-env=%s params=%s", + {n: dest_env[n] for n in OSMORPHING_OPTION_NAMES if n in dest_env}, + {n: params[n] for n in OSMORPHING_OPTION_NAMES if n in params}, + ) + + for name in OSMORPHING_OPTION_NAMES: + if name in dest_env: + value = dest_env[name] + elif name not in params: + value = getattr(CONF, name, None) + else: + value = None + if value is None: + continue + params[name] = value + LOG.info("Applying destination option '%s': %s", name, value) + + osmorphing_info["osmorphing_parameters"] = params + LOG.info( + "Destination options after apply: params=%s", + {n: params[n] for n in OSMORPHING_OPTION_NAMES if n in params}, + ) + return osmorphing_info diff --git a/coriolis/osmorphing/manager.py b/coriolis/osmorphing/manager.py index 69be32cfe..aa5e819c6 100644 --- a/coriolis/osmorphing/manager.py +++ b/coriolis/osmorphing/manager.py @@ -362,7 +362,7 @@ def _morph_image( import_os_morphing_tools.pre_packages_install(packages_add) nics_info = osmorphing_info.get('nics_info') - set_dhcp = osmorphing_info.get('nics_set_dhcp', True) + set_dhcp = osmorphing_parameters.get('set_dhcp', True) import_os_morphing_tools.set_net_config(nics_info, dhcp=set_dhcp) LOG.info("Pre packages") diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index 1bafd766b..24f3a4abe 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -9,6 +9,7 @@ import re import uuid +from oslo_config import cfg from oslo_log import log as logging from packaging import version @@ -16,6 +17,7 @@ from coriolis.osmorphing import base from coriolis.osmorphing.osdetect import windows as windows_osdetect +CONF = cfg.CONF LOG = logging.getLogger(__name__) WINDOWS_CLIENT_IDENTIFIER = windows_osdetect.WINDOWS_CLIENT_IDENTIFIER @@ -45,6 +47,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', @@ -529,6 +544,74 @@ def _write_local_script(self, base_dir, script_path, priority=50): remote_script_path, ) + def _parse_cloudbase_init_plugins(self, plugins): + """Return a list of plugin class names, or None if unset. + + A CSV string is split on commas. Tokens are stripped. + Empty tokens are skipped. + """ + if plugins is None: + return None + if isinstance(plugins, str): + text = plugins.strip() + if not text: + return [] + parsed = [] + for raw_token in text.split(","): + token = raw_token.strip() + if not token: + continue + parsed.append(token) + return parsed + if not isinstance(plugins, list): + raise exception.CoriolisException( + "Invalid plugins parameter. Must be list." + ) + parsed = [] + for item in plugins: + if not isinstance(item, str): + raise exception.CoriolisException( + "Invalid plugins parameter. Must be list." + ) + token = item.strip() + if token: + parsed.append(token) + return parsed + + 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. provider-supplied ``plugins`` argument + 3. coriolis.conf ``cloudbase_init_plugins`` + 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 + + conf_plugins = self._parse_cloudbase_init_plugins(CONF.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, @@ -541,12 +624,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..4fd569cbb 100644 --- a/coriolis/schemas/os_morphing_resources_schema.json +++ b/coriolis/schemas/os_morphing_resources_schema.json @@ -41,10 +41,6 @@ "type": "object" } }, - "nics_set_dhcp": { - "type": "boolean", - "default": "true" - }, "ignore_devices": { "type": "array", "items": { @@ -57,6 +53,18 @@ "retain_user_credentials": { "type": "boolean", "default": false + }, + "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, the provider plugin list is used, then coriolis.conf, then the Windows morphing default." + }, + "set_dhcp": { + "type": "boolean", + "default": true, + "description": "Configure guest NICs with DHCP during 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..314ed3590 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,238 @@ 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", + "osmorphing_parameters": {"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["osmorphing_parameters"]["set_dhcp"]) + self.assertNotIn( + "cloudbase_init_plugins", osmorphing_info["osmorphing_parameters"] + ) + + 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_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["osmorphing_parameters"]["set_dhcp"]) + + 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.assertEqual( + ["SSH", "HTTPS"], result["properties"]["data_transfer_mechanism"]["enum"] + ) + self.assertEqual("boolean", result["properties"]["set_dhcp"]["type"]) + self.assertNotIn("cloudbase_init_plugins", schema["properties"]) + self.assertNotIn("data_transfer_mechanism", schema["properties"]) + self.assertNotIn("set_dhcp", 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"]) + + 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.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"]) + + 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) + + def test_filter_core_option_names(self): + result = dest_opts.filter_core_option_names( + [ + "zone", + "cloudbase_init_plugins", + "data_transfer_mechanism", + "set_dhcp", + "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 = self._row_by_name( + 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 = self._row_by_name( + 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["osmorphing_parameters"]["set_dhcp"]) + self.assertNotIn("set_dhcp", osmorphing_info) + + def test_apply_core_destination_overrides_set_dhcp_conf_fallback(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {}, + } + previous = dest_opts.CONF.set_dhcp + dest_opts.CONF.set_dhcp = False + try: + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + finally: + dest_opts.CONF.set_dhcp = previous + self.assertFalse(result["osmorphing_parameters"]["set_dhcp"]) + + def test_apply_core_destination_overrides_set_dhcp_keeps_provider(self): + osmorphing_info = { + "os_type": "linux", + "osmorphing_parameters": {"set_dhcp": True}, + } + previous = dest_opts.CONF.set_dhcp + dest_opts.CONF.set_dhcp = False + try: + result = dest_opts.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + finally: + dest_opts.CONF.set_dhcp = previous + self.assertTrue(result["osmorphing_parameters"]["set_dhcp"]) + + def test_apply_core_destination_overrides_set_dhcp_dest_env_wins(self): + previous = dest_opts.CONF.set_dhcp + dest_opts.CONF.set_dhcp = True + try: + result = dest_opts.apply_core_destination_overrides( + { + "os_type": "linux", + "osmorphing_parameters": { + "set_dhcp": True, + }, + }, + {"set_dhcp": False}, + ) + finally: + dest_opts.CONF.set_dhcp = previous + self.assertFalse(result["osmorphing_parameters"]["set_dhcp"]) diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index 220eff560..82020c70a 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -592,12 +592,78 @@ 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'] + windows.CONF.set_override('cloudbase_init_plugins', conf_plugins) + self.addCleanup(windows.CONF.clear_override, 'cloudbase_init_plugins') + 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'] + windows.CONF.set_override('cloudbase_init_plugins', conf_plugins) + self.addCleanup(windows.CONF.clear_override, 'cloudbase_init_plugins') + 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'] + windows.CONF.set_override( + 'cloudbase_init_plugins', ['cloudbaseinit.plugins.common.mtu.MTUPlugin'] + ) + self.addCleanup(windows.CONF.clear_override, 'cloudbase_init_plugins') + 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_csv_string(self): + csv_plugins = ( + "cloudbaseinit.plugins.common.mtu.MTUPlugin, " + "cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin" + ) + self.morphing_tools._osmorphing_parameters = { + "cloudbase_init_plugins": csv_plugins + } + result = self.morphing_tools._resolve_cloudbase_init_plugins() + self.assertEqual( + result, + [ + 'cloudbaseinit.plugins.common.mtu.MTUPlugin', + 'cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin', + ], + ) + @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..5838c636c 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,54 @@ 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", + "osmorphing_parameters": {"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"]["osmorphing_parameters"]["set_dhcp"]) + 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..eb9411e3a 100644 --- a/etc/coriolis/coriolis.conf +++ b/etc/coriolis/coriolis.conf @@ -2,6 +2,15 @@ log_dir=/tmp log_file=coriolis.log messaging_transport_url=rabbit://coriolis:Passw0rd@127.0.0.1:5672/ +# Comma-separated Cloudbase-Init plugin class names for Windows OS morphing. +# Used when dest-env and the destination provider both omit the key. +# cloudbase_init_plugins = cloudbaseinit.plugins.common.mtu.MTUPlugin,cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin +# Disk copy mechanism for destination worker VMs: HTTPS (TCP/5566) or SSH (TCP/22). +# data_transfer_mechanism = HTTPS +# Configure guest NICs with DHCP during OS morphing. +# Used when dest-env and the destination provider both omit set_dhcp. +# Default is true. +# set_dhcp = true [keystone_authtoken] auth_type = password 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.