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
76 changes: 45 additions & 31 deletions coriolisclient/cli/deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,37 +161,51 @@ class CreateDeployment(show.ShowOne):
"""Start a new deployment from an existing transfer"""
def get_parser(self, prog_name):
parser = super(CreateDeployment, self).get_parser(prog_name)
parser.add_argument('transfer',
help='The ID of the transfer to migrate')
parser.add_argument('--force',
help='Force the deployment in case of a transfer '
'with failed executions', action='store_true',
default=False)
parser.add_argument('--dont-clone-disks',
help='Retain the transfer disks by cloning them',
action='store_false', dest="clone_disks",
default=True)
parser.add_argument('--skip-os-morphing',
help='Skip the OS morphing process',
action='store_true',
default=False)
parser.add_argument('--user-script-global', action='append',
required=False,
dest="global_scripts",
help='A script that will run for a particular '
'os_type. This option can be used multiple '
'times. Use: linux=/path/to/script.sh or '
'windows=/path/to/script.ps1')
parser.add_argument('--user-script-instance', action='append',
required=False,
dest="instance_scripts",
help='A script that will run for a particular '
'instance specified by the --instance option. '
'This option can be used multiple times. '
'Use: "instance_name"=/path/to/script.sh.'
' This option overwrites any OS specific script '
'specified in --user-script-global for this '
'instance')
parser.add_argument(
'transfer',
help='The ID of the transfer to migrate')
parser.add_argument(
'--force',
help='Force the deployment in case of a transfer '
'with failed executions',
action='store_true',
default=False)
parser.add_argument(
'--dont-clone-disks',
help='Retain the transfer disks by cloning them',
action='store_false',
dest="clone_disks",
default=True)
parser.add_argument(
'--skip-os-morphing',
help='Skip the OS morphing process',
action='store_true',
default=False)
parser.add_argument(
'--user-script-global',
action='append',
required=False,
dest="global_scripts",
help='A script that will run for a particular os_type. This '
'option can be used multiple times. '
'Use: linux=/path/to/script.sh or '
'windows=/path/to/script.ps1. '
'Can optionally include a script phase: '
'windows=/path/to/script.ps1,phase=osmorphing_pre_os_mount.')
parser.add_argument(
'--user-script-instance',
action='append',
required=False,
dest="instance_scripts",
help='A script that will run for a particular '
'instance specified by the --instance option. '
'This option can be used multiple times. '
'Use: "instance_name"=/path/to/script.sh.'
' This option overwrites any OS specific script '
'specified in --user-script-global for this '
'instance. Can optionally include a script phase: '
'instance_name=/path/to/script.ps1,'
'phase=osmorphing_pre_os_mount.')
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.

should we also mention what the phases are, and what the default is?

cli_utils.add_minion_pool_args_to_parser(
parser, include_origin_pool_arg=False,
include_destination_pool_arg=False,
Expand Down
129 changes: 104 additions & 25 deletions coriolisclient/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,42 +191,121 @@ def get_option_value_from_args(args, option_name, error_on_no_value=True):
return value


def comma_separated_kv_to_dict(input_string: str) -> dict:
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.

hmm, I wonder. cliff uses argsparse. Could we instead have something like:

        parser.add_argument(
            '--user-script-instance',
            action='append',
            required=False,
            dest="instance_scripts",
            type=comma_separated_kv_to_dict,
            default={},

If it can, this may also simplify the handling before, we basically let cliff handle this for us.

Source: https://docs.python.org/3/library/argparse.html#type

...
User defined functions can be used as well:

def hyphenated(string):
    return '-'.join([word[:4] for word in string.casefold().split()])

parser = argparse.ArgumentParser()
_ = parser.add_argument('short_title', type=hyphenated)
parser.parse_args(['"The Tale of Two Cities"'])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, I'll give it a try.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It works, it's just that the original error message gets discarded. For example:

root@coriolis:~/python-coriolisclient# coriolis deployment create c5a08378-301c-459d-a547-39a562776b62 --user-script-global windows=/root/user_scripts/bitlocker/unlock_using_pwd.ps1,phaseosmorphing_pre_os_moun
t
usage: coriolis deployment create [-h] [-f {json,shell,table,value,yaml}] [-c COLUMN] [--noindent] [--prefix PREFIX] [--max-width <integer>] [--fit-width] [--print-empty] [--force] [--dont-clone-disks]
                                  [--skip-os-morphing] [--user-script-global GLOBAL_SCRIPTS] [--user-script-instance INSTANCE_SCRIPTS]
                                  [--osmorphing-minion-pool-mapping INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
                                  transfer
coriolis deployment create: error: argument --user-script-global: invalid comma_separated_kv_to_dict value: 'windows=/root/user_scripts/bitlocker/unlock_using_pwd.ps1,phaseosmorphing_pre_os_mount'

The resulting error message might be good enough though.

"""Convert a comma separated list of key=value pairs to dict.

Example: some_key=some_val,some_other_key=some_other_val
-> {"some_key": "some_val", "some_other_key": "some_other_val"}
"""
out = {}
kv_pairs = input_string.split(",")
for kv_pair in kv_pairs:
try:
key, value = kv_pair.split("=")
except ValueError:
raise ValueError("Not a <key>=<value> pair: %s" % kv_pair)
out[key] = value
return out


def compose_user_scripts(global_scripts, instance_scripts):
ret = {
"global": {},
"instances": {}
}
global_scripts = global_scripts or []
instance_scripts = instance_scripts or []
for glb in global_scripts:
split = glb.split("=", 1)
if len(split) != 2:
continue
if split[0] not in constants.OS_LIST:
for global_script_str_params in global_scripts:
try:
params = comma_separated_kv_to_dict(global_script_str_params)
except ValueError:
raise ValueError(
"Invalid global user script parameter: %s. Expecting "
"<os_type>=<script_path>. Can optionally include a comma "
"separated phase parameter, "
"e.g. <os_type>=<script_path>,phase=<phase>" %
global_script_str_params)
Comment thread
Dany9966 marked this conversation as resolved.
phase = params.pop("phase", constants.PHASE_OSMORPHING_POST_OS_MOUNT)
if phase not in constants.USER_SCRIPT_PHASES:
raise ValueError(
f"Invalid user script phase: {phase}. "
"Available options are: "
f"{', '.join(constants.USER_SCRIPT_PHASES)}.")
if not params:
raise ValueError(
"OS type not specified. "
"Available options are: %s" % ", ".join(constants.OS_LIST))
if len(params.keys()) > 1:
raise ValueError(
"Too many parameters. Expecting just the OS type.")
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.

That's a bit confusing, considering we accept the phase too.

os_type = list(params.keys())[0]
script_path = params[os_type]
if os_type not in constants.OS_LIST:
raise ValueError(
"Invalid OS %s. Available options are: %s" % (
split[0], ", ".join(constants.OS_LIST)))
if not split[1]:
# removing script
ret["global"][split[0]] = None
continue
if os.path.isfile(split[1]) is False:
raise ValueError("Could not find %s" % split[1])
with open(split[1]) as sc:
ret["global"][split[0]] = sc.read()

for inst in instance_scripts:
split = inst.split("=", 1)
if len(split) != 2:
os_type, ", ".join(constants.OS_LIST)))

payload = None
# The user may omit the script path in order to remove all script
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you please mention this in the helper string of the command?

Copy link
Copy Markdown
Member Author

@petrutlucian94 petrutlucian94 May 26, 2026

Choose a reason for hiding this comment

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

The behavior was already there but indeed, it should be documented.

# records.
if not script_path:
ret["global"][os_type] = None
continue
if not split[1]:
# removing script
ret['instances'][split[0]] = None

if not os.path.isfile(script_path):
raise ValueError("Could not find %s" % script_path)
with open(script_path) as sc:
payload = sc.read()
if os_type not in ret["global"]:
ret["global"][os_type] = []
script_entry = {
"phase": phase,
"payload": payload,
}
ret["global"][os_type].append(script_entry)

for instance_scripts_str_params in instance_scripts:
try:
params = comma_separated_kv_to_dict(instance_scripts_str_params)
except ValueError:
raise ValueError(
Comment thread
Dany9966 marked this conversation as resolved.
"Invalid instance user script parameter: %s. Expecting "
"<instance>=<script_path>. Can optionally include a comma "
"separated phase parameter, "
"e.g. <instance>=<script_path>,phase=<phase>" %
instance_scripts_str_params)

phase = params.pop("phase", constants.PHASE_OSMORPHING_POST_OS_MOUNT)
if phase not in constants.USER_SCRIPT_PHASES:
raise ValueError(
f"Invalid user script phase: {phase}. "
"Available options are: "
f"{', '.join(constants.USER_SCRIPT_PHASES)}.")
if not params:
raise ValueError("Instance not specified.")
if len(params.keys()) > 1:
raise ValueError(
"Too many parameters. Expecting just one instance.")
instance = list(params.keys())[0]
script_path = params[instance]
payload = None
# The user may omit the script path in order to remove all script
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as above

# records.
if not script_path:
ret["instances"][instance] = None
continue
if os.path.isfile(split[1]) is False:
raise ValueError("Could not find %s" % split[1])
with open(split[1]) as sc:
ret["instances"][split[0]] = sc.read()

if not os.path.isfile(script_path):
raise ValueError("Could not find %s" % script_path)
with open(script_path) as sc:
payload = sc.read()
if instance not in ret["instances"]:
ret["instances"][instance] = []
script_entry = {
"phase": phase,
"payload": payload,
}
ret["instances"][instance].append(script_entry)
return ret


Expand Down
17 changes: 17 additions & 0 deletions coriolisclient/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,20 @@
OS_TYPE_OTHER,
OS_TYPE_UNKNOWN,
]

# 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,
]
75 changes: 66 additions & 9 deletions coriolisclient/tests/cli/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
from coriolisclient.cli import utils
from coriolisclient.tests import test_base

_user_script_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'data/user_scripts.yml')


@ddt.ddt
class UtilsTestCase(test_base.CoriolisBaseTestCase):
Expand Down Expand Up @@ -252,11 +256,7 @@ def test_get_option_value_from_args_json_value_error(self):
{
"global_scripts": ["linux script"],
"instance_scripts": ["linux script"],
"expected_result":
{
"global": {},
"instances": {}
}
"expected_result": None
},
{
"global_scripts": ["invalid_os=scrips"],
Expand All @@ -273,6 +273,20 @@ def test_get_option_value_from_args_json_value_error(self):
"instance_scripts": ["linux='invalid/file/path'"],
"expected_result": None
},
{
"global_scripts": None,
# Too many parameters.
"instance_scripts": [
f"linux={_user_script_path},windows={_user_script_path}"], # noqa
"expected_result": None
},
{
"global_scripts": None,
# Invalid phase.
"instance_scripts": [
f"linux={_user_script_path},phase=invalid-phase"], # noqa
"expected_result": None
}
)
def test_compose_user_scripts(self, data):
global_scripts = data["global_scripts"]
Expand All @@ -298,15 +312,58 @@ def test_compose_user_scripts(self, data):
def test_compose_user_scripts_from_file(self):
script_path = os.path.dirname(os.path.realpath(__file__))
script_path = os.path.join(script_path, 'data/user_scripts.yml')
global_scripts = ["linux=%s" % script_path]
instance_scripts = ["linux=%s" % script_path]
global_scripts = [
f"linux={script_path}",
f"windows={script_path},phase=osmorphing_pre_os_mount", # noqa
f"windows={script_path},phase=osmorphing_post_os_mount", # noqa
]
instance_scripts = [
f"instance0={script_path}",
f"instance1={script_path}",
f"instance1={script_path},phase=osmorphing_pre_os_mount", # noqa
]

result = utils.compose_user_scripts(global_scripts, instance_scripts)

payload = '"mock_script1"\n"mock_script2"\n'
self.assertEqual(
{
'global': {'linux': '"mock_script1"\n"mock_script2"\n'},
'instances': {'linux': '"mock_script1"\n"mock_script2"\n'}
'global': {
'linux': [
{
'phase': "osmorphing_post_os_mount",
'payload': payload,
},
],
'windows': [
{
'phase': "osmorphing_pre_os_mount",
'payload': payload,
},
{
'phase': "osmorphing_post_os_mount",
'payload': payload,
},
],
},
'instances': {
'instance0': [
{
'phase': "osmorphing_post_os_mount",
'payload': payload,
},
],
'instance1': [
{
'phase': "osmorphing_post_os_mount",
'payload': payload,
},
{
'phase': "osmorphing_pre_os_mount",
'payload': payload,
},
],
},
},
result
)
Expand Down
Loading