Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions coriolis/osmorphing/conf.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion coriolis/osmorphing/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
90 changes: 84 additions & 6 deletions coriolis/osmorphing/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
import re
import uuid

from oslo_config import cfg
from oslo_log import log as logging
from packaging import version

from coriolis import constants, exception, utils
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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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):
Comment thread
Cristi1324 marked this conversation as resolved.
"""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,
Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions coriolis/schemas/os_morphing_resources_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@
"type": "object"
}
},
"nics_set_dhcp": {
"type": "boolean",
"default": "true"
},
"ignore_devices": {
"type": "array",
"items": {
Expand All @@ -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": {
Comment thread
Cristi1324 marked this conversation as resolved.
"type": "boolean",
"default": true,
"description": "Configure guest NICs with DHCP during OS morphing."
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions coriolis/tasks/osmorphing_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading