From 873a87681a6bc918c0ac347f71e1ad55c61943a6 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Thu, 22 Oct 2026 15:09:17 +0300 Subject: [PATCH 1/2] 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. From 5e717ae8f72a4e062bb83825c3c798139e909a27 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Thu, 22 Oct 2026 15:10:25 +0300 Subject: [PATCH 2/2] Add the Cloudbase-Init plugin list as a common destination option. Register cloudbase_init_plugins with the common destination options and the destination schema. OS morphing stores the list on the morphing parameters. Windows reads that list, then the provider argument, then coriolis.conf, then the default plugin list. --- coriolis/osmorphing/conf.py | 152 ++++++++++++++++++ coriolis/osmorphing/windows.py | 25 +-- coriolis/providers/conf.py | 6 +- .../schemas/os_morphing_resources_schema.json | 7 + coriolis/tests/osmorphing/test_manager.py | 71 ++++++++ coriolis/tests/osmorphing/test_windows.py | 98 ++++++++++- coriolis/tests/tasks/test_osmorphing_tasks.py | 52 ++++++ etc/coriolis/coriolis.conf | 4 + 8 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 coriolis/osmorphing/conf.py diff --git a/coriolis/osmorphing/conf.py b/coriolis/osmorphing/conf.py new file mode 100644 index 000000000..3c47be281 --- /dev/null +++ b/coriolis/osmorphing/conf.py @@ -0,0 +1,152 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""OS morphing destination options registered with oslo.config. + +Add one entry to ``_OPTIONS`` for each new option. +``coriolis.providers.conf`` copies ``SCHEMA_PROPERTIES``, +``DESTINATION_OPTIONS``, and ``OVERRIDES``. +The option group name matches +``coriolis.providers.conf.COMMON_TARGET_OPTION_GROUP``. +""" + +from oslo_config import cfg +from oslo_log import log as logging + +from coriolis import exception + +# providers.conf uses this same group name. +COMMON_TARGET_OPTION_GROUP = "common_target_options" + +CLOUDBASE_INIT_DEFAULT_PLUGINS = [ + "cloudbaseinit.plugins.common.mtu.MTUPlugin", + "cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin", + "cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin", + "cloudbaseinit.plugins.common.networkconfig.NetworkConfigPlugin", + "cloudbaseinit.plugins.windows.licensing.WindowsLicensingPlugin", + "cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin", + "cloudbaseinit.plugins.common.userdata.UserDataPlugin", + "cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin", + "cloudbaseinit.plugins.windows.winrmlistener.ConfigWinRMListenerPlugin", + "cloudbaseinit.plugins.windows.winrmcertificateauth." + "ConfigWinRMCertificateAuthPlugin", + "cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin", +] + +CLOUDBASE_INIT_PLUGINS_SCHEMA = { + "type": "array", + "items": {"type": "string"}, + "title": "Cloudbase-Init Plugins", + "description": ( + "Custom Cloudbase-Init plugin class names for Windows guests. " + "This overrides the destination provider's list of " + "cloudbase-init plugins that get enabled after migrating " + "Windows guests." + ), +} + +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_PLUGINS_OVERRIDE = { + "option": "cloudbase_init_plugins", + "field": "cloudbase_init_plugins", + "target": "parameters", +} + +# name: destination environment key. +# schema: JSON Schema fragment injected into the provider schema. +# destination_option: wizard row. Its name matches the key. +# override: where apply_core_destination_overrides stores the value. +# config: oslo option in COMMON_TARGET_OPTION_GROUP. +_OPTIONS = ( + { + "name": "cloudbase_init_plugins", + "schema": CLOUDBASE_INIT_PLUGINS_SCHEMA, + "destination_option": CLOUDBASE_INIT_PLUGINS_DESTINATION_OPTION, + "override": CLOUDBASE_INIT_PLUGINS_OVERRIDE, + "config": cfg.ListOpt( + "cloudbase_init_plugins", + default=None, + help=CLOUDBASE_INIT_PLUGINS_SCHEMA["description"], + ), + }, +) + +SCHEMA_PROPERTIES = {spec["name"]: spec["schema"] for spec in _OPTIONS} +DESTINATION_OPTIONS = {spec["name"]: spec["destination_option"] for spec in _OPTIONS} +OVERRIDES = tuple(spec["override"] for spec in _OPTIONS) + +opts = [spec["config"] for spec in _OPTIONS] + +CONF = cfg.CONF +CONF.register_opts(opts, COMMON_TARGET_OPTION_GROUP) + +LOG = logging.getLogger(__name__) + + +def _parse_cloudbase_init_plugins(plugins: list | None) -> list | None: + """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( + osmorphing_parameters: dict, plugins: list | None = None +) -> list: + """Return the Cloudbase-Init plugin list to write into the guest. + + ``osmorphing_parameters`` is a dict. ``plugins`` is a list or None. + + Resolved in this order: + + 1. osmorphing_parameters ``cloudbase_init_plugins``. + ``apply_core_destination_overrides`` stores the coriolis.conf + value here before the provider argument is read. + 2. provider-supplied ``plugins`` argument + 3. coriolis.conf ``[common_target_options] cloudbase_init_plugins`` + 4. ``CLOUDBASE_INIT_DEFAULT_PLUGINS`` + """ + if not isinstance(osmorphing_parameters, dict): + raise exception.CoriolisException( + "Invalid OS morphing parameters type: %s. Expected a dict." + % type(osmorphing_parameters).__name__ + ) + param_plugins = _parse_cloudbase_init_plugins( + 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 = _parse_cloudbase_init_plugins(plugins) + if provider_plugins is not None: + return provider_plugins + + conf_plugins = _parse_cloudbase_init_plugins( + CONF.common_target_options.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) diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index bd34fc277..c275643d5 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -14,6 +14,7 @@ from coriolis import constants, exception, utils from coriolis.osmorphing import base +from coriolis.osmorphing import conf as osmorphing_conf from coriolis.osmorphing.osdetect import windows as windows_osdetect LOG = logging.getLogger(__name__) @@ -30,21 +31,6 @@ RUN_PATH_FORMAT = "HKLM:\\%s\\\Microsoft\\Windows\\CurrentVersion\\Run" UNINSTALL_PATH_FORMAT = "HKLM:\\%s\\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*" CLOUDBASEINIT_SERVICE_NAME = "cloudbase-init" -CLOUDBASE_INIT_DEFAULT_PLUGINS = [ - 'cloudbaseinit.plugins.common.mtu.MTUPlugin', - 'cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin', - 'cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin', - 'cloudbaseinit.plugins.common.networkconfig.NetworkConfigPlugin', - 'cloudbaseinit.plugins.windows.licensing.WindowsLicensingPlugin', - 'cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin', - 'cloudbaseinit.plugins.common.userdata.UserDataPlugin', - 'cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin', - 'cloudbaseinit.plugins.windows.winrmlistener.ConfigWinRMListenerPlugin', - 'cloudbaseinit.plugins.windows.winrmcertificateauth.' - 'ConfigWinRMCertificateAuthPlugin', - 'cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin', -] - CLOUDBASE_INIT_DEFAULT_METADATA_SVCS = [ 'cloudbaseinit.metadata.services.httpservice.HttpService', 'cloudbaseinit.metadata.services.configdrive.ConfigDriveService', @@ -543,12 +529,9 @@ 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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self._osmorphing_parameters, plugins + ) LOG.info("Writing Cloudbase-Init configuration files") conf_dir = "%s\\conf" % cloudbaseinit_base_dir diff --git a/coriolis/providers/conf.py b/coriolis/providers/conf.py index b1ec42822..2163428d8 100644 --- a/coriolis/providers/conf.py +++ b/coriolis/providers/conf.py @@ -19,10 +19,12 @@ from oslo_log import log as logging from coriolis import exception +from coriolis.osmorphing import conf as osmorphing_conf -COMMON_TARGET_OPTION_GROUP = "common_target_options" +COMMON_TARGET_OPTION_GROUP = osmorphing_conf.COMMON_TARGET_OPTION_GROUP CORE_SCHEMA_PROPERTIES = { + **osmorphing_conf.SCHEMA_PROPERTIES, "data_transfer_mechanism": { "type": "string", "title": "Data Transfer Mechanism", @@ -62,6 +64,7 @@ } CORE_DESTINATION_OPTIONS = { + **osmorphing_conf.DESTINATION_OPTIONS, "data_transfer_mechanism": { "name": "data_transfer_mechanism", "values": ["SSH", "HTTPS"], @@ -84,6 +87,7 @@ # target "parameters": write the field on osmorphing_parameters. # target "info": write the field on osmorphing_info. OSMORPHING_OVERRIDES = ( + *osmorphing_conf.OVERRIDES, { "option": "set_dhcp", "field": "nics_set_dhcp", diff --git a/coriolis/schemas/os_morphing_resources_schema.json b/coriolis/schemas/os_morphing_resources_schema.json index e02855166..ae4c621b0 100644 --- a/coriolis/schemas/os_morphing_resources_schema.json +++ b/coriolis/schemas/os_morphing_resources_schema.json @@ -59,6 +59,13 @@ "type": "boolean", "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": "Custom Cloudbase-Init plugin class names for Windows guests. This overrides the destination provider's list of cloudbase-init plugins that get enabled after migrating Windows guests." } } } diff --git a/coriolis/tests/osmorphing/test_manager.py b/coriolis/tests/osmorphing/test_manager.py index 0490cdc47..ef908baf8 100644 --- a/coriolis/tests/osmorphing/test_manager.py +++ b/coriolis/tests/osmorphing/test_manager.py @@ -422,6 +422,57 @@ def test_morph_image_dismount_os_exception( 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 = provider_conf.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 = provider_conf.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}, + } + provider_conf.CONF.set_override( + "cloudbase_init_plugins", + ["other.Plugin"], + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + self.addCleanup( + provider_conf.CONF.clear_override, + "cloudbase_init_plugins", + group=provider_conf.COMMON_TARGET_OPTION_GROUP, + ) + result = provider_conf.apply_core_destination_overrides( + osmorphing_info, {"zone": "zone1"} + ) + self.assertEqual( + result["osmorphing_parameters"]["cloudbase_init_plugins"], plugins + ) + def test_apply_core_destination_overrides_rejects_non_dict(self): self.assertRaises( exception.InvalidInput, @@ -441,6 +492,9 @@ def test_apply_core_destination_overrides_plugins_omitted(self): result = provider_conf.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"]) @@ -452,6 +506,7 @@ def test_inject_core_target_environment_schema_simple(self): "additionalProperties": False, } result = provider_conf.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"]) @@ -462,6 +517,7 @@ def test_inject_core_target_environment_schema_simple(self): 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"]) @@ -479,6 +535,7 @@ def test_inject_core_target_environment_schema_keeps_provider(self): 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.assertIn("cloudbase_init_plugins", result["properties"]) self.assertEqual("string", schema["properties"]["set_dhcp"]["type"]) def test_inject_core_target_environment_schema_oneof(self): @@ -490,6 +547,7 @@ def test_inject_core_target_environment_schema_oneof(self): } result = provider_conf.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"]) @@ -504,6 +562,7 @@ 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("cloudbase_init_plugins", names) self.assertIn("data_transfer_mechanism", names) self.assertIn("set_dhcp", names) self.assertIn("retain_user_credentials", names) @@ -554,6 +613,7 @@ 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("cloudbase_init_plugins", names) self.assertIn("data_transfer_mechanism", names) self.assertIn("set_dhcp", names) self.assertIn("retain_user_credentials", names) @@ -572,6 +632,7 @@ def test_filter_core_option_names(self): result = provider_conf.filter_core_option_names( [ "zone", + "cloudbase_init_plugins", "data_transfer_mechanism", "set_dhcp", "retain_user_credentials", @@ -593,6 +654,16 @@ def test_get_data_transfer_mechanism_destination_option(self): self.assertEqual(["SSH", "HTTPS"], result["values"]) self.assertEqual("HTTPS", result["config_default"]) + def test_get_cloudbase_init_plugins_destination_option(self): + result = provider_conf.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", diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index 220eff560..61ddeb084 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 @@ -579,7 +580,7 @@ def test__write_cloudbase_init_conf( "metadata_services": ",".join( windows.CLOUDBASE_INIT_DEFAULT_METADATA_SVCS ), - "plugins": ",".join(windows.CLOUDBASE_INIT_DEFAULT_PLUGINS), + "plugins": ",".join(osmorphing_conf.CLOUDBASE_INIT_DEFAULT_PLUGINS), "real_time_clock_utc": False, } ) @@ -592,12 +593,105 @@ 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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self.osmorphing_parameters, provider_plugins + ) + self.assertEqual(result, provider_plugins) + + def test_get_cloudbase_init_plugins_destination_option(self): + result = osmorphing_conf.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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self.osmorphing_parameters + ) + self.assertEqual(result, osmorphing_conf.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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self.osmorphing_parameters, 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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self.osmorphing_parameters + ) + 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 = osmorphing_conf.resolve_cloudbase_init_plugins( + self.morphing_tools._osmorphing_parameters, + ['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, + osmorphing_conf.resolve_cloudbase_init_plugins, + self.morphing_tools._osmorphing_parameters, + ) + @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 e97a305cc..e76c65e4e 100644 --- a/coriolis/tests/tasks/test_osmorphing_tasks.py +++ b/coriolis/tests/tasks/test_osmorphing_tasks.py @@ -157,6 +157,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/etc/coriolis/coriolis.conf b/etc/coriolis/coriolis.conf index 23c5567f6..ea94d74ca 100644 --- a/etc/coriolis/coriolis.conf +++ b/etc/coriolis/coriolis.conf @@ -4,6 +4,10 @@ log_file=coriolis.log messaging_transport_url=rabbit://coriolis:Passw0rd@127.0.0.1:5672/ [common_target_options] +# Custom Cloudbase-Init plugin class names for Windows guests. +# This overrides the destination provider's list of cloudbase-init plugins +# that get enabled after migrating Windows guests. +# 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.