Skip to content
Draft
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
220 changes: 220 additions & 0 deletions coriolis/osmorphing/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# 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 written into Windows guests during "
"OS morphing. Destination environment takes precedence. "
"When dest-env omits this key, the destination provider "
"value is used. When the provider also omits it, "
"coriolis.conf is used if set, otherwise the Windows "
"morphing default."
),
},
"data_transfer_mechanism": {
"type": "string",
"title": "Data Transfer Mechanism",
"enum": ["SSH", "HTTPS"],
"description": (
"Mechanism used to send disk data from Coriolis to "
"destination worker VMs. HTTPS uses TCP/5566 and is "
"faster. SSH uses TCP/22. When dest-env omits this key, "
"the destination provider value is used. When the "
"provider also omits it, coriolis.conf is used if set. "
"Default is HTTPS."
),
},
"set_dhcp": {
"type": "boolean",
"title": "Set DHCP",
"description": (
"Configure guest NICs with DHCP during OS morphing. "
"Destination environment takes precedence. When dest-env "
"omits this key, the provider value is kept. When the "
"provider also omits it, coriolis.conf set_dhcp is used. "
"Default is true."
),
},
}

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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems duplicated from #501, I assume that PR is the best location for this, no?

"""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": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, there is also nics_set_dhcp above. How does that interact with this?

"type": "boolean",
"default": true,
"description": "Configure guest NICs with DHCP during OS morphing."
}
}
}
Expand Down
Loading
Loading