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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
"skip_os_morphing": false,
"instance_osmorphing_minion_pool_mappings": {
"instance1": "morphing_pool"
},
"user_scripts": {
"global": {
"linux": [
{
"phase": "osmorphing_pre_os_mount",
"payload": "echo 'unlocking encrypted OS partion'"
},
{
"phase": "osmorphing_post_os_mount",
"payload": "echo 'modifying replica OS filesystem'"
}
]
}
}
}
}
108 changes: 107 additions & 1 deletion coriolis/api/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,103 @@ def _build_keyerror_message(resource, method, key):
return msg


def _sanitize_newlines(payload: str) -> str:
# Convert \r\n or \n\r to \n.
return payload.replace('\r\n', '\n').replace('\n\r', '\n')


def _process_user_scripts(
user_scripts: list[dict] | str | None,
sanitize_newlines: bool = False,
) -> list[dict]:
# Process the user script(s), returning an "extended format"
# list. See 'validate_user_scripts for more details.
if user_scripts is None:
return []
elif isinstance(user_scripts, str):
# Basic script format.
payload = user_scripts
if sanitize_newlines:
payload = _sanitize_newlines(payload)
return [
{
"phase": constants.PHASE_OSMORPHING_POST_OS_MOUNT,
"payload": payload,
},
]
elif isinstance(user_scripts, list):
# Extended format.
for script_item in user_scripts:
if not isinstance(script_item, dict):
raise exception.InvalidInput(
reason="Invalid user script list item, expecting dict.")

allowed_keys = ["payload", "phase"]
for key in script_item:
if key not in allowed_keys:
raise exception.InvalidInput(
reason=(f"Invalid script item key: {key}, "
f"allowed keys: {allowed_keys}"))

if not script_item.get("phase"):
script_item["phase"] = constants.PHASE_OSMORPHING_POST_OS_MOUNT
if script_item["phase"] not in constants.USER_SCRIPT_PHASES:
raise exception.InvalidInput(
reason=(
f"Unknown user script phase: {script_item['phase']}, "
f"supported phases: {constants.USER_SCRIPT_PHASES}."))
if "payload" not in script_item:
raise exception.InvalidInput(
reason="Missing 'payload' field.")
if not isinstance(script_item["payload"], str):
raise exception.InvalidInput(
reason="Invalid payload type, expecting string.")

if sanitize_newlines:
script_item["payload"] = _sanitize_newlines(
script_item["payload"])

return user_scripts
else:
raise exception.InvalidInput(
reason=("Invalid user script format. Expecting a string or a "
"list of dicts containing the payload and phase."))


def validate_user_scripts(user_scripts):
# Validate the top level user scripts dict.
# Example:
# {
# # Globally executed scripts, used if there are no per-instance
# # scripts.
# 'global': {
# # Basic format: single script in string format.
# # Executed
# 'windows': 'write-host "hello-world!"'
# # Extended format: a list of scripts, allowing the execution
# # phase to be specified.
# 'linux': [
# {
# 'phase': 'osmorphing_pre_os_mount',
# 'payload': 'echo "configuring LUKS"',
# },
# {
# 'phase': 'osmorphing_post_os_mount',
# 'payload': 'echo "modifying OS configuration"'
# }
# ]
# },
# # Per instance scripts.
# 'instances': {
# 'instance-id': [
# # Extended format without an explicit execution phase,
# # Defaulting to osmorphing_post_os_mount.
# {
# 'payload': 'echo "modifying OS configuration"'
# }
# ]
# }
# }
if user_scripts is None:
user_scripts = {}
if not isinstance(user_scripts, dict):
Expand All @@ -104,14 +200,24 @@ def validate_user_scripts(user_scripts):
reason='The provided global user script os_type "%s" is '
'invalid. Must be one of the '
'following: %s' % (os_type, constants.VALID_OS_TYPES))
global_scripts[os_type] = _process_user_scripts(
global_scripts[os_type],
sanitize_newlines=(os_type == constants.OS_TYPE_LINUX))

instance_scripts = user_scripts.get('instances', {})
if not isinstance(instance_scripts, dict):
raise exception.InvalidInput(
reason='"instances" must be a mapping between the identifiers of '
'the instances in the Replica/Migration and their '
'respective scripts.')

for instance_id in instance_scripts:
# The conductor used to do this, sanitizing newlines regardless
# of the instance OS types.
# TODO(lpetrut): consider moving it to the OS morphing side, which
# has the OS type at hand.
instance_scripts[instance_id] = _process_user_scripts(
instance_scripts[instance_id],
sanitize_newlines=True)
return user_scripts


Expand Down
7 changes: 0 additions & 7 deletions coriolis/conductor/rpc/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,13 +1724,6 @@ def _normalize_user_scripts(self, user_scripts, instances):
instance, instances)
user_scripts['instances'].pop(instance, None)
continue
user_scripts['instances'][instance] = (
user_scripts['instances'][instance].replace('\r\n', '\n').
replace('\n\r', '\n'))
linux_scripts = user_scripts.get('global', {}).get('linux')
if linux_scripts:
user_scripts['global']['linux'] = (
linux_scripts.replace('\r\n', '\n').replace('\n\r', '\n'))
return user_scripts

@transfer_synchronized
Expand Down
17 changes: 17 additions & 0 deletions coriolis/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,20 @@
MINION_MACHINE_POWER_STATUS_POWERED_OFF = "POWERED_OFF"
MINION_MACHINE_POWER_STATUS_POWERING_ON = "POWERING_ON"
MINION_MACHINE_POWER_STATUS_POWERING_OFF = "POWERING_OFF"

# User script execution phases.
#
# Scripts that must be executed before the OS partition is mounted, for
# example scripts that unlock encrypted partitions.
PHASE_OSMORPHING_PRE_OS_MOUNT = "osmorphing_pre_os_mount"
# Scripts that are executed after the OS partition is mounted (the default).
PHASE_OSMORPHING_POST_OS_MOUNT = "osmorphing_post_os_mount"
# We may eventually add "PHASE_REPLICA_FIRST_BOOT" for convenience, although
# the users can already achieve this by using os-morphing scripts to schedule
# scripts that will be executed at the next boot. This may require import
# provider support.

USER_SCRIPT_PHASES = [
PHASE_OSMORPHING_PRE_OS_MOUNT,
PHASE_OSMORPHING_POST_OS_MOUNT,
]
4 changes: 4 additions & 0 deletions coriolis/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ class MinionMachineCommandTimeout(CoriolisException):
pass


class SSHCommandFailed(CoriolisException):
pass


class SSHCommandNotFoundException(CoriolisException):
pass

Expand Down
26 changes: 22 additions & 4 deletions coriolis/osmorphing/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from oslo_config import cfg
from oslo_log import log as logging

from coriolis import constants
from coriolis import events
from coriolis import exception
from coriolis.osmorphing import base as base_osmorphing
Expand Down Expand Up @@ -126,11 +127,12 @@ def get_osmorphing_tools_class_for_provider(


def morph_image(origin_provider, destination_provider, connection_info,
osmorphing_info, user_script, event_handler):
osmorphing_info, user_scripts, event_handler):
event_manager = events.EventManager(event_handler)

os_type = osmorphing_info.get('os_type')
ignore_devices = osmorphing_info.get('ignore_devices', [])
user_scripts = user_scripts or []

# instantiate and run OSMount tools:
os_mount_tools = osmount_factory.get_os_mount_tools(
Expand All @@ -150,6 +152,19 @@ def morph_image(origin_provider, destination_provider, connection_info,
"OSMorphing minion machine and the VM undergoing OSMorphing. "
"Error was: %s" % str(err)) from err

pre_os_mount_user_scripts = [
script["payload"] for script in user_scripts
if script["phase"] == constants.PHASE_OSMORPHING_PRE_OS_MOUNT]
if not pre_os_mount_user_scripts:
event_manager.progress_update(
'No pre-os-mount OS morphing user script specified')
for user_script in pre_os_mount_user_scripts:
event_manager.progress_update(
'Running pre-os-mount OS morphing user script')
# We haven't detected the morphed OS partition yet, we'll use
# the mount tools to run pre-mount user scripts.
os_mount_tools.run_user_script(user_script)

event_manager.progress_update("Discovering and mounting OS partitions")
os_root_dir, os_root_dev = os_mount_tools.mount_os()

Expand Down Expand Up @@ -210,13 +225,16 @@ def morph_image(origin_provider, destination_provider, connection_info,
CONF.default_osmorphing_operation_timeout)
import_os_morphing_tools.set_environment(environment)

if user_script:
post_os_mount_user_scripts = [
script["payload"] for script in user_scripts
if script["phase"] == constants.PHASE_OSMORPHING_POST_OS_MOUNT]
for user_script in post_os_mount_user_scripts:
event_manager.progress_update(
'Running OS morphing user script')
import_os_morphing_tools.run_user_script(user_script)
else:
if not post_os_mount_user_scripts:
event_manager.progress_update(
'No OS morphing user script specified')
'No post-os-mount OS morphing user script specified')

event_manager.progress_update(
'OS being migrated: %s' % detected_os_info['friendly_release_name'])
Expand Down
33 changes: 33 additions & 0 deletions coriolis/osmorphing/osmount/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ def set_proxy(self, proxy_settings):
def get_environment(self):
return self._environment

@abc.abstractmethod
def run_user_script(self, user_script):
"""Run pre-os-mount user script."""
pass


class BaseSSHOSMountTools(BaseOSMountTools):
@utils.retry_on_error(max_attempts=5, sleep_seconds=3)
Expand Down Expand Up @@ -674,3 +679,31 @@ def set_proxy(self, proxy_settings):
if no_proxy:
LOG.debug("Proxy exclusions: %s", no_proxy)
self._environment["no_proxy"] = '.'.join(no_proxy)

def run_user_script(self, user_script):
if len(user_script) == 0:
return

script_path = "/tmp/coriolis_user_script"
try:
utils.write_ssh_file(
self._ssh,
script_path,
user_script)
except Exception as err:
raise exception.CoriolisException(
"Failed to copy user script to target system.") from err

try:
utils.exec_ssh_cmd(
self._ssh,
"sudo chmod +x %s" % script_path,
get_pty=True)

utils.exec_ssh_cmd(
self._ssh,
f'sudo "{script_path}"',
get_pty=True)
except Exception as err:
raise exception.CoriolisException(
"Failed to run user script.") from err
22 changes: 22 additions & 0 deletions coriolis/osmorphing/osmount/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,25 @@ def mount_os(self):

def dismount_os(self, root_drive):
self._bring_nonboot_disks_offline()

def run_user_script(self, user_script):
if len(user_script) == 0:
return

script_path = "$env:TMP\\coriolis_user_script.ps1"
try:
utils.write_winrm_file(
self._conn,
script_path,
user_script)
except Exception as err:
raise exception.CoriolisException(
"Failed to copy user script to target system.") from err

cmd = f'& "{script_path}"; exit $LASTEXITCODE'
try:
out = self._conn.exec_ps_command(cmd)
LOG.debug("User script output: %s" % out)
except Exception as err:
raise exception.CoriolisException(
"Failed to run user script.") from err
4 changes: 1 addition & 3 deletions coriolis/osmorphing/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,7 @@ def run_user_script(self, user_script):
raise exception.CoriolisException(
"Failed to copy user script to target system.") from err

cmd = ('$ErrorActionPreference = "Stop"; powershell.exe '
'-NonInteractive -ExecutionPolicy RemoteSigned '
'-File "%(script)s" "%(os_root_dir)s"') % {
cmd = ('& "%(script)s" "%(os_root_dir)s"; exit $LASTEXITCODE') % {
"script": script_path,
"os_root_dir": self._os_root_dir,
}
Expand Down
19 changes: 14 additions & 5 deletions coriolis/tasks/osmorphing_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,30 @@ def _run(self, ctxt, instance, origin, destination, task_info,
osmorphing_info = task_info.get('osmorphing_info', {})

user_scripts = task_info.get("user_scripts")
instance_script = None
instance_scripts = None
if user_scripts:
instance_script = user_scripts.get("instances", {}).get(instance)
if not instance_script:
instance_scripts = user_scripts.get("instances", {}).get(instance)
if not instance_scripts:
os_type = osmorphing_info.get("os_type")
if os_type:
instance_script = user_scripts.get(
instance_scripts = user_scripts.get(
"global", {}).get(os_type)

if isinstance(instance_scripts, str):
# Legacy record, convert to extended format.
instance_scripts = [
{
"phase": constants.PHASE_OSMORPHING_POST_OS_MOUNT,
"payload": instance_scripts,
}
]

osmorphing_manager.morph_image(
origin_provider,
destination_provider,
osmorphing_connection_info,
osmorphing_info,
instance_script,
instance_scripts,
event_handler)

return {}
Expand Down
Loading
Loading