Skip to content

Commit f51a123

Browse files
Alow specifying the os-morphing user script phase
When deploying replicas, Coriolis users can specify scripts that will be executed during os-morphing, right after the OS partition is mounted on the minion instance. In some situations, this is too late since user scripts may be needed in order to be able to mount the OS disk, for example if it’s encrypted. In other situations it’s too early. Some scripts may need to be executed on the replica instance. This may require provider assistance. To accommodate these use cases, we’ve extended the deployment API, allowing the user to specify when a given script should be executed. Each script may specify one of the following execution phases: * osmorphing-pre-os-mount * osmorphing-post-os-mount (default) * replica-initial-boot (TBD) Samples: * Explicit phase --user-script-global linux=/some/script.sh,phase=osmorphing-pre-os-mount * Implicit phase, defaults to osmorphing-post-os-mount --user-script-global linux=/some/script.sh * Repeating the flag, specifying multiple scripts: --user-script-instance some-instance=/some/script.sh,phase=osmorphing-pre-os-mount --user-script-instance some-instance=/other/script.sh,phase=osmorphing-pre-os-mount --user-script-instance some-instance=/another/script.sh,phase=osmorphing-post-os-mount
1 parent d6c7fc1 commit f51a123

4 files changed

Lines changed: 232 additions & 65 deletions

File tree

coriolisclient/cli/deployments.py

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -161,37 +161,51 @@ class CreateDeployment(show.ShowOne):
161161
"""Start a new deployment from an existing transfer"""
162162
def get_parser(self, prog_name):
163163
parser = super(CreateDeployment, self).get_parser(prog_name)
164-
parser.add_argument('transfer',
165-
help='The ID of the transfer to migrate')
166-
parser.add_argument('--force',
167-
help='Force the deployment in case of a transfer '
168-
'with failed executions', action='store_true',
169-
default=False)
170-
parser.add_argument('--dont-clone-disks',
171-
help='Retain the transfer disks by cloning them',
172-
action='store_false', dest="clone_disks",
173-
default=True)
174-
parser.add_argument('--skip-os-morphing',
175-
help='Skip the OS morphing process',
176-
action='store_true',
177-
default=False)
178-
parser.add_argument('--user-script-global', action='append',
179-
required=False,
180-
dest="global_scripts",
181-
help='A script that will run for a particular '
182-
'os_type. This option can be used multiple '
183-
'times. Use: linux=/path/to/script.sh or '
184-
'windows=/path/to/script.ps1')
185-
parser.add_argument('--user-script-instance', action='append',
186-
required=False,
187-
dest="instance_scripts",
188-
help='A script that will run for a particular '
189-
'instance specified by the --instance option. '
190-
'This option can be used multiple times. '
191-
'Use: "instance_name"=/path/to/script.sh.'
192-
' This option overwrites any OS specific script '
193-
'specified in --user-script-global for this '
194-
'instance')
164+
parser.add_argument(
165+
'transfer',
166+
help='The ID of the transfer to migrate')
167+
parser.add_argument(
168+
'--force',
169+
help='Force the deployment in case of a transfer '
170+
'with failed executions',
171+
action='store_true',
172+
default=False)
173+
parser.add_argument(
174+
'--dont-clone-disks',
175+
help='Retain the transfer disks by cloning them',
176+
action='store_false',
177+
dest="clone_disks",
178+
default=True)
179+
parser.add_argument(
180+
'--skip-os-morphing',
181+
help='Skip the OS morphing process',
182+
action='store_true',
183+
default=False)
184+
parser.add_argument(
185+
'--user-script-global',
186+
action='append',
187+
required=False,
188+
dest="global_scripts",
189+
help='A script that will run for a particular os_type. This '
190+
'option can be used multiple times. '
191+
'Use: linux=/path/to/script.sh or '
192+
'windows=/path/to/script.ps1. '
193+
'Can optionally include a script phase: '
194+
'windows=/path/to/script.ps1,phase=osmorphing_pre_os_mount.')
195+
parser.add_argument(
196+
'--user-script-instance',
197+
action='append',
198+
required=False,
199+
dest="instance_scripts",
200+
help='A script that will run for a particular '
201+
'instance specified by the --instance option. '
202+
'This option can be used multiple times. '
203+
'Use: "instance_name"=/path/to/script.sh.'
204+
' This option overwrites any OS specific script '
205+
'specified in --user-script-global for this '
206+
'instance. Can optionally include a script phase: '
207+
'instance_name=/path/to/script.ps1,'
208+
'phase=osmorphing_pre_os_mount.')
195209
cli_utils.add_minion_pool_args_to_parser(
196210
parser, include_origin_pool_arg=False,
197211
include_destination_pool_arg=False,

coriolisclient/cli/utils.py

Lines changed: 104 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -191,42 +191,121 @@ def get_option_value_from_args(args, option_name, error_on_no_value=True):
191191
return value
192192

193193

194+
def comma_separated_kv_to_dict(input_string: str) -> dict:
195+
"""Convert a comma separated list of key=value pairs to dict.
196+
197+
Example: some_key=some_val,some_other_key=some_other_val
198+
-> {"some_key": "some_val", "some_other_key": "some_other_val"}
199+
"""
200+
out = {}
201+
kv_pairs = input_string.split(",")
202+
for kv_pair in kv_pairs:
203+
try:
204+
key, value = kv_pair.split("=")
205+
except ValueError:
206+
raise ValueError("Not a <key>=<value> pair: %s" % kv_pair)
207+
out[key] = value
208+
return out
209+
210+
194211
def compose_user_scripts(global_scripts, instance_scripts):
195212
ret = {
196213
"global": {},
197214
"instances": {}
198215
}
199216
global_scripts = global_scripts or []
200217
instance_scripts = instance_scripts or []
201-
for glb in global_scripts:
202-
split = glb.split("=", 1)
203-
if len(split) != 2:
204-
continue
205-
if split[0] not in constants.OS_LIST:
218+
for global_script_str_params in global_scripts:
219+
try:
220+
params = comma_separated_kv_to_dict(global_script_str_params)
221+
except ValueError:
222+
raise ValueError(
223+
"Invalid global user script parameter: %s. Expecting "
224+
"<os_type>=<script_path>. Can optionally include a comma "
225+
"separated phase parameter, "
226+
"e.g. <os_type>=<script_path>,phase=<phase>" %
227+
global_script_str_params)
228+
phase = params.pop("phase", constants.PHASE_OSMORPHING_POST_OS_MOUNT)
229+
if phase not in constants.USER_SCRIPT_PHASES:
230+
raise ValueError(
231+
f"Invalid user script phase: {phase}. "
232+
"Available options are: "
233+
f"{', '.join(constants.USER_SCRIPT_PHASES)}.")
234+
if not params:
235+
raise ValueError(
236+
"OS type not specified. "
237+
"Available options are: %s" % ", ".join(constants.OS_LIST))
238+
if len(params.keys()) > 1:
239+
raise ValueError(
240+
"Too many parameters. Expecting just the OS type.")
241+
os_type = list(params.keys())[0]
242+
script_path = params[os_type]
243+
if os_type not in constants.OS_LIST:
206244
raise ValueError(
207245
"Invalid OS %s. Available options are: %s" % (
208-
split[0], ", ".join(constants.OS_LIST)))
209-
if not split[1]:
210-
# removing script
211-
ret["global"][split[0]] = None
212-
continue
213-
if os.path.isfile(split[1]) is False:
214-
raise ValueError("Could not find %s" % split[1])
215-
with open(split[1]) as sc:
216-
ret["global"][split[0]] = sc.read()
217-
218-
for inst in instance_scripts:
219-
split = inst.split("=", 1)
220-
if len(split) != 2:
246+
os_type, ", ".join(constants.OS_LIST)))
247+
248+
payload = None
249+
# The user may omit the script path in order to remove all script
250+
# records.
251+
if not script_path:
252+
ret["global"][os_type] = None
221253
continue
222-
if not split[1]:
223-
# removing script
224-
ret['instances'][split[0]] = None
254+
255+
if not os.path.isfile(script_path):
256+
raise ValueError("Could not find %s" % script_path)
257+
with open(script_path) as sc:
258+
payload = sc.read()
259+
if os_type not in ret["global"]:
260+
ret["global"][os_type] = []
261+
script_entry = {
262+
"phase": phase,
263+
"payload": payload,
264+
}
265+
ret["global"][os_type].append(script_entry)
266+
267+
for instance_scripts_str_params in instance_scripts:
268+
try:
269+
params = comma_separated_kv_to_dict(instance_scripts_str_params)
270+
except ValueError:
271+
raise ValueError(
272+
"Invalid instance user script parameter: %s. Expecting "
273+
"<instance>=<script_path>. Can optionally include a comma "
274+
"separated phase parameter, "
275+
"e.g. <instance>=<script_path>,phase=<phase>" %
276+
instance_scripts_str_params)
277+
278+
phase = params.pop("phase", constants.PHASE_OSMORPHING_POST_OS_MOUNT)
279+
if phase not in constants.USER_SCRIPT_PHASES:
280+
raise ValueError(
281+
f"Invalid user script phase: {phase}. "
282+
"Available options are: "
283+
f"{', '.join(constants.USER_SCRIPT_PHASES)}.")
284+
if not params:
285+
raise ValueError("Instance not specified.")
286+
if len(params.keys()) > 1:
287+
raise ValueError(
288+
"Too many parameters. Expecting just one instance.")
289+
instance = list(params.keys())[0]
290+
script_path = params[instance]
291+
payload = None
292+
# The user may omit the script path in order to remove all script
293+
# records.
294+
if not script_path:
295+
ret["instances"][instance] = None
225296
continue
226-
if os.path.isfile(split[1]) is False:
227-
raise ValueError("Could not find %s" % split[1])
228-
with open(split[1]) as sc:
229-
ret["instances"][split[0]] = sc.read()
297+
298+
if not os.path.isfile(script_path):
299+
raise ValueError("Could not find %s" % script_path)
300+
with open(script_path) as sc:
301+
payload = sc.read()
302+
if instance not in ret["instances"]:
303+
ret["instances"][instance] = []
304+
script_entry = {
305+
"phase": phase,
306+
"payload": payload,
307+
}
308+
ret["instances"][instance].append(script_entry)
230309
return ret
231310

232311

coriolisclient/constants.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,20 @@
4848
OS_TYPE_OTHER,
4949
OS_TYPE_UNKNOWN,
5050
]
51+
52+
# User script execution phases.
53+
#
54+
# Scripts that must be executed before the OS partition is mounted, for
55+
# example scripts that unlock encrypted partitions.
56+
PHASE_OSMORPHING_PRE_OS_MOUNT = "osmorphing_pre_os_mount"
57+
# Scripts that are executed after the OS partition is mounted (the default).
58+
PHASE_OSMORPHING_POST_OS_MOUNT = "osmorphing_post_os_mount"
59+
# We may eventually add "PHASE_REPLICA_FIRST_BOOT" for convenience, although
60+
# the users can already achieve this by using os-morphing scripts to schedule
61+
# scripts that will be executed at the next boot. This may require import
62+
# provider support.
63+
64+
USER_SCRIPT_PHASES = [
65+
PHASE_OSMORPHING_PRE_OS_MOUNT,
66+
PHASE_OSMORPHING_POST_OS_MOUNT,
67+
]

coriolisclient/tests/cli/test_utils.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from coriolisclient.cli import utils
1010
from coriolisclient.tests import test_base
1111

12+
_user_script_path = os.path.join(
13+
os.path.dirname(os.path.realpath(__file__)),
14+
'data/user_scripts.yml')
15+
1216

1317
@ddt.ddt
1418
class UtilsTestCase(test_base.CoriolisBaseTestCase):
@@ -252,11 +256,7 @@ def test_get_option_value_from_args_json_value_error(self):
252256
{
253257
"global_scripts": ["linux script"],
254258
"instance_scripts": ["linux script"],
255-
"expected_result":
256-
{
257-
"global": {},
258-
"instances": {}
259-
}
259+
"expected_result": None
260260
},
261261
{
262262
"global_scripts": ["invalid_os=scrips"],
@@ -273,6 +273,20 @@ def test_get_option_value_from_args_json_value_error(self):
273273
"instance_scripts": ["linux='invalid/file/path'"],
274274
"expected_result": None
275275
},
276+
{
277+
"global_scripts": None,
278+
# Too many parameters.
279+
"instance_scripts": [
280+
f"linux={_user_script_path},windows={_user_script_path}"], # noqa
281+
"expected_result": None
282+
},
283+
{
284+
"global_scripts": None,
285+
# Invalid phase.
286+
"instance_scripts": [
287+
f"linux={_user_script_path},phase=invalid-phase"], # noqa
288+
"expected_result": None
289+
}
276290
)
277291
def test_compose_user_scripts(self, data):
278292
global_scripts = data["global_scripts"]
@@ -298,15 +312,58 @@ def test_compose_user_scripts(self, data):
298312
def test_compose_user_scripts_from_file(self):
299313
script_path = os.path.dirname(os.path.realpath(__file__))
300314
script_path = os.path.join(script_path, 'data/user_scripts.yml')
301-
global_scripts = ["linux=%s" % script_path]
302-
instance_scripts = ["linux=%s" % script_path]
315+
global_scripts = [
316+
f"linux={script_path}",
317+
f"windows={script_path},phase=osmorphing_pre_os_mount", # noqa
318+
f"windows={script_path},phase=osmorphing_post_os_mount", # noqa
319+
]
320+
instance_scripts = [
321+
f"instance0={script_path}",
322+
f"instance1={script_path}",
323+
f"instance1={script_path},phase=osmorphing_pre_os_mount", # noqa
324+
]
303325

304326
result = utils.compose_user_scripts(global_scripts, instance_scripts)
305327

328+
payload = '"mock_script1"\n"mock_script2"\n'
306329
self.assertEqual(
307330
{
308-
'global': {'linux': '"mock_script1"\n"mock_script2"\n'},
309-
'instances': {'linux': '"mock_script1"\n"mock_script2"\n'}
331+
'global': {
332+
'linux': [
333+
{
334+
'phase': "osmorphing_post_os_mount",
335+
'payload': payload,
336+
},
337+
],
338+
'windows': [
339+
{
340+
'phase': "osmorphing_pre_os_mount",
341+
'payload': payload,
342+
},
343+
{
344+
'phase': "osmorphing_post_os_mount",
345+
'payload': payload,
346+
},
347+
],
348+
},
349+
'instances': {
350+
'instance0': [
351+
{
352+
'phase': "osmorphing_post_os_mount",
353+
'payload': payload,
354+
},
355+
],
356+
'instance1': [
357+
{
358+
'phase': "osmorphing_post_os_mount",
359+
'payload': payload,
360+
},
361+
{
362+
'phase': "osmorphing_pre_os_mount",
363+
'payload': payload,
364+
},
365+
],
366+
},
310367
},
311368
result
312369
)

0 commit comments

Comments
 (0)