Skip to content

Commit 4e35b25

Browse files
Allow registering replica-first-boot scripts
We've recently extended the Coriolis API to allow specifying *when* a given user script should be executed. We currently support the following phases: * osmorphing_pre_os_mount * osmorphing_post_os_mount For convenience, we'll also add the following phase: * replica_first_boot These scripts will be executed when the replica VM boots for the first time. We'll inject them during os-morphing. As you may have noticed, the user can already do this but it's quite inconvenient to pass a script that injects another first-boot script. We'll rely on systemd on Linux and cloudbase-init on Windows. All the Linux distributions that we support have switched to Systemd about 10 years ago. Also, as per this commit [1], we rely on the fact that cloudbase-init will be available all the time and that we can use it to run first-boot scripts. Note that we did consider using scheduled tasks on Windows (as opposed to Cloudbase-init). We'd need to use an xml task definition and register it using registry keys, however it seems like we lack the privileges to create entries such as the following: ``` $HIVE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\$TaskGUID" ``` [1] b5f93fd
1 parent 8dc0bd6 commit 4e35b25

8 files changed

Lines changed: 255 additions & 8 deletions

File tree

coriolis/constants.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,12 +390,11 @@
390390
PHASE_OSMORPHING_PRE_OS_MOUNT = "osmorphing_pre_os_mount"
391391
# Scripts that are executed after the OS partition is mounted (the default).
392392
PHASE_OSMORPHING_POST_OS_MOUNT = "osmorphing_post_os_mount"
393-
# We may eventually add "PHASE_REPLICA_FIRST_BOOT" for convenience, although
394-
# the users can already achieve this by using os-morphing scripts to schedule
395-
# scripts that will be executed at the next boot. This may require import
396-
# provider support.
393+
# Scripts that are executed when the replica VM starts for the first time.
394+
PHASE_REPLICA_FIRST_BOOT = "replica_first_boot"
397395

398396
USER_SCRIPT_PHASES = [
399397
PHASE_OSMORPHING_PRE_OS_MOUNT,
400398
PHASE_OSMORPHING_POST_OS_MOUNT,
399+
PHASE_REPLICA_FIRST_BOOT,
401400
]

coriolis/osmorphing/base.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,48 @@
2828
CLOUD_INIT_SERVICE_UNIT_NAME = "cloud-init"
2929
CLOUD_INIT_SERVICE_UNIT_NAME_FALLBACK = "cloud-init-main"
3030

31+
FIRST_BOOT_SCRIPT_RUNNER = """#!/bin/bash
32+
function run_scripts {
33+
script_dir=$1
34+
35+
for f in $script_dir/*.sh; do
36+
if [ -x "$f" ]; then
37+
echo "Invoking script: $f"
38+
"$f"
39+
echo "Exit code: $?"
40+
fi
41+
done
42+
}
43+
44+
# Run Coriolis provided scripts.
45+
run_scripts /usr/lib/coriolis/firstboot/service
46+
47+
# Run user provided scripts.
48+
run_scripts /usr/lib/coriolis/firstboot/user
49+
50+
mkdir -p /var/lib/coriolis
51+
touch /var/lib/coriolis/firstboot-complete
52+
"""
53+
FIRST_BOOT_SCRIPT_RUNNER_PATH = "/usr/lib/coriolis/firstboot/run-firstboot.sh"
54+
FIRST_BOOT_SYSTEMD_UNIT = """
55+
[Unit]
56+
Description=Coriolis replica first-boot scripts.
57+
After=network-online.target
58+
Wants=network-online.target
59+
ConditionPathExists=!/var/lib/coriolis/firstboot-complete
60+
61+
[Service]
62+
Type=oneshot
63+
ExecStart=/usr/lib/coriolis/firstboot/run-firstboot.sh
64+
RemainAfterExit=yes
65+
66+
[Install]
67+
WantedBy=multi-user.target
68+
"""
69+
FIRST_BOOT_SYSTEMD_UNIT_NAME = "coriolis-firstboot.service"
70+
FIRST_BOOT_SYSTEMD_UNIT_PATH = (
71+
f"/etc/systemd/system/{FIRST_BOOT_SYSTEMD_UNIT_NAME}")
72+
3173

3274
class BaseOSMorphingTools(object, with_metaclass(abc.ABCMeta)):
3375

@@ -100,6 +142,15 @@ def get_packages(self):
100142
def run_user_script(self, user_script):
101143
pass
102144

145+
@abc.abstractmethod
146+
def register_firstboot_script(
147+
self,
148+
script: str,
149+
index: int = 0,
150+
user_provided=True,
151+
):
152+
pass
153+
103154
@abc.abstractmethod
104155
def pre_packages_install(self, package_names):
105156
pass
@@ -719,3 +770,53 @@ def _setup_network_preservation(self, nics_info) -> None:
719770
self._add_net_udev_rules(net_ifaces_info)
720771

721772
return
773+
774+
def register_firstboot_script(
775+
self,
776+
script: str,
777+
index: int = 0,
778+
user_provided=True,
779+
):
780+
if len(script) == 0:
781+
LOG.debug("Empty first-boot script, skipping...")
782+
return
783+
784+
if user_provided:
785+
script_dir = "/usr/lib/coriolis/firstboot/user"
786+
else:
787+
script_dir = "/usr/lib/coriolis/firstboot/service"
788+
unique_id = str(uuid.uuid4()).split("-")[0]
789+
script_path = os.path.join(script_dir, f"{index:02d}-{unique_id}.sh")
790+
791+
self._exec_cmd_chroot(f"mkdir -p {script_dir}")
792+
self._write_file_sudo(script_path, script)
793+
self._exec_cmd_chroot(f"chown root:root {script_path}")
794+
self._exec_cmd_chroot(f"chmod 755 {script_path}")
795+
796+
# systemd unit used to launch first-boot scripts.
797+
if not self._test_path(FIRST_BOOT_SYSTEMD_UNIT_PATH):
798+
self._write_file_sudo(
799+
FIRST_BOOT_SYSTEMD_UNIT_PATH, FIRST_BOOT_SYSTEMD_UNIT)
800+
self._exec_cmd_chroot(
801+
"chown root:root %s" % FIRST_BOOT_SYSTEMD_UNIT_PATH)
802+
self._exec_cmd_chroot(
803+
"chmod 644 %s" % FIRST_BOOT_SYSTEMD_UNIT_PATH)
804+
wants_dir = "/etc/systemd/system/multi-user.target.wants"
805+
self._exec_cmd_chroot("mkdir -p %s" % wants_dir)
806+
self._exec_cmd_chroot(
807+
"ln -sf %s %s/%s" % (
808+
FIRST_BOOT_SYSTEMD_UNIT_PATH,
809+
wants_dir,
810+
FIRST_BOOT_SYSTEMD_UNIT_NAME))
811+
812+
# A script that iterates over "/usr/lib/coriolis/firstboot/*.sh"
813+
# scripts and runs them.
814+
if not self._test_path(FIRST_BOOT_SCRIPT_RUNNER_PATH):
815+
self._write_file_sudo(
816+
FIRST_BOOT_SCRIPT_RUNNER_PATH, FIRST_BOOT_SCRIPT_RUNNER)
817+
self._exec_cmd_chroot(
818+
"chown root:root %s" % FIRST_BOOT_SCRIPT_RUNNER_PATH)
819+
self._exec_cmd_chroot(
820+
"chmod 755 %s" % FIRST_BOOT_SCRIPT_RUNNER_PATH)
821+
822+
LOG.info(f"Registered first-boot script: {script_path}")

coriolis/osmorphing/manager.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,13 @@ def _morph_image(origin_provider, destination_provider, connection_info,
307307

308308
LOG.info("Post packages install")
309309
import_os_morphing_tools.post_packages_install(packages_add)
310+
311+
first_boot_user_scripts = [
312+
script["payload"] for script in user_scripts
313+
if script["phase"] == constants.PHASE_REPLICA_FIRST_BOOT]
314+
for script_idx, user_script in enumerate(first_boot_user_scripts):
315+
event_manager.progress_update('Registering first-boot user script')
316+
import_os_morphing_tools.register_firstboot_script(
317+
user_script, script_idx, user_provided=True)
318+
if not first_boot_user_scripts:
319+
event_manager.progress_update('No first-boot user script specified')

coriolis/osmorphing/windows.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,14 +456,19 @@ def _get_cbslinit_scripts_dir(self, base_dir):
456456

457457
def _write_local_script(self, base_dir, script_path, priority=50):
458458
scripts_dir = self._get_cbslinit_scripts_dir(base_dir)
459-
script = "%s\\%d-%s" % (
459+
remote_script_path = "%s\\%02d-%s" % (
460460
scripts_dir, priority,
461461
os.path.basename(script_path))
462462

463463
with open(script_path, 'r') as fd:
464464
contents = fd.read()
465465
utils.write_winrm_file(
466-
self._conn, script, contents)
466+
self._conn, remote_script_path, contents)
467+
468+
LOG.info(
469+
"Registered first-boot Coriolis script: %s -> %s",
470+
script_path,
471+
remote_script_path)
467472

468473
def _write_cloudbase_init_conf(self, cloudbaseinit_base_dir,
469474
local_base_dir, com_port="COM1",
@@ -525,7 +530,7 @@ def _write_cloudbase_init_conf(self, cloudbaseinit_base_dir,
525530

526531
self._write_local_script(
527532
cloudbaseinit_base_dir, disks_script,
528-
priority=99)
533+
priority=10)
529534

530535
def _install_cloudbase_init(self, download_url,
531536
metadata_services=None, enabled_plugins=None,
@@ -723,3 +728,38 @@ def uninstall_packages(self, package_names):
723728

724729
def post_packages_uninstall(self, package_names):
725730
pass
731+
732+
def register_firstboot_script(
733+
self,
734+
script: str,
735+
index: int = 0,
736+
user_provided=True,
737+
):
738+
if len(script) == 0:
739+
LOG.debug("Empty first-boot script, skipping...")
740+
return
741+
742+
if user_provided:
743+
# The default priority for Coriolis scripts is "50",
744+
# some using below 50.
745+
#
746+
# The scripts are executed in alphabetical order, so the
747+
# ones with a lower "priority" will be executed first.
748+
#
749+
# We'll bump the priority here so that user scripts will
750+
# run after the Coriolis internal scripts.
751+
index += 51
752+
753+
cbslinit_base_dir = self._get_cbslinit_base_dir()
754+
script_dir = self._get_cbslinit_scripts_dir(cbslinit_base_dir)
755+
unique_id = str(uuid.uuid4()).split("-")[0]
756+
script_path = os.path.join(
757+
script_dir, f"{index:02d}-{unique_id}.ps1")
758+
759+
self._conn.exec_ps_command(f"mkdir -Force {script_dir}")
760+
utils.write_winrm_file(
761+
self._conn,
762+
script_path,
763+
script)
764+
765+
LOG.info(f"Registered first-boot script: {script_path}")

coriolis/tests/integration/deployments/test_osmorphing.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
installation in the target OS.
88
"""
99

10+
import os
11+
import re
1012
import uuid
1113

1214
from coriolis.tests.integration import base as integration_base
@@ -135,3 +137,54 @@ def test_os_morphing_global_script_extended_format(self):
135137
# the replica OS disk was mounted.
136138
self.assertNotIn(self._dst_device, pre_mounts)
137139
self.assertIn(self._dst_device, post_mounts)
140+
141+
def test_os_morphing_global_script_first_boot(self):
142+
payload = "mount > /boot_mounts"
143+
user_scripts = {
144+
'global': {
145+
'linux': [
146+
{
147+
"phase": "replica_fist_boot",
148+
"payload": "mount > /boot_mounts",
149+
},
150+
],
151+
'windows': [
152+
{
153+
"phase": "replica_fist_boot",
154+
"payload": "should-not-get-executed",
155+
},
156+
]
157+
}
158+
}
159+
deployment_kwargs = {
160+
"user_scripts": user_scripts,
161+
}
162+
self._execute_transfer_and_deployment(deployment_kwargs)
163+
164+
# TODO(lpetrut): the test import provider doesn't actually create
165+
# replica instances (containers). If it did, we'd have no way to clean
166+
# them up using Coriolis APIs.
167+
#
168+
# For this reason, we can't ensure that the first boot scripts
169+
# actually get executed. We'll merely verify that those files
170+
# have been injected at the expected location.
171+
first_boot_script_dir = "usr/lib/coriolis/firstboot/user"
172+
first_boot_scripts = test_utils.list_files_from_device(
173+
self._dst_device, first_boot_script_dir)
174+
if not first_boot_scripts:
175+
raise AssertionError("Couldn't find first boot script dir.")
176+
177+
found = False
178+
for file_name in first_boot_scripts:
179+
if re.match(r"\d+-\w+\.sh", file_name):
180+
first_boot_script_path = os.path.join(
181+
first_boot_script_dir, file_name)
182+
first_boot_script = test_utils.read_file_from_device(
183+
self._dst_device,
184+
first_boot_script_path)
185+
if payload == first_boot_script:
186+
found = True
187+
188+
if not found:
189+
raise AssertionError(
190+
"Couldn't find the expected first boot script.")

coriolis/tests/integration/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,18 @@ def read_file_from_device(device_path, rel_path):
400400
return f.read()
401401
finally:
402402
_run(["umount", mount_point])
403+
404+
405+
def list_files_from_device(device_path, rel_path):
406+
"""Enumerates files from the filesystem of *device_path*.
407+
408+
Mounts the device read-only into a temporary directory, enumerates files,
409+
then unmounts.
410+
"""
411+
with tempfile.TemporaryDirectory() as mount_point:
412+
_run(["mount", "-o", "ro", device_path, mount_point])
413+
414+
try:
415+
return os.listdir(os.path.join(mount_point, rel_path))
416+
finally:
417+
_run(["umount", mount_point])

coriolis/tests/osmorphing/test_manager.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ def setUp(self):
2929
"phase": constants.PHASE_OSMORPHING_POST_OS_MOUNT,
3030
"payload": "post-os-mount-script",
3131
},
32+
{
33+
"phase": constants.PHASE_REPLICA_FIRST_BOOT,
34+
"payload": "fist-boot-script",
35+
},
3236
]
3337

3438
manager.CONF.proxy.url = "http://127.0.0.1:8080"
@@ -203,6 +207,14 @@ def install_packages(self, packages_add):
203207
def uninstall_packages(self, packages_remove):
204208
pass
205209

210+
def register_firstboot_script(
211+
self,
212+
script: str,
213+
index: int = 0,
214+
user_provided=True,
215+
):
216+
pass
217+
206218
@mock.patch.object(manager.osmount_factory, 'get_os_mount_tools')
207219
@mock.patch.object(manager.events, 'EventManager')
208220
@mock.patch.object(manager, 'run_os_detect')

coriolis/tests/osmorphing/test_windows.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ def test__write_cloudbase_init_conf(
521521
self.conn, conf_file_path, conf_content)
522522

523523
mock_write_local_script.assert_called_once_with(
524-
'C:\\Cloudbase-Init', mocked_full_path, priority=99)
524+
'C:\\Cloudbase-Init', mocked_full_path, priority=10)
525525

526526
@mock.patch.object(windows.utils, 'write_winrm_file')
527527
@mock.patch.object(windows.BaseWindowsMorphingTools, '_write_local_script')
@@ -946,3 +946,20 @@ def test_set_net_config_with_dhcp(
946946
mock_unload_registry_hive.assert_not_called()
947947

948948
self.assertIsNone(result)
949+
950+
@mock.patch.object(windows.utils, 'write_winrm_file')
951+
@mock.patch("uuid.uuid4")
952+
def register_firstboot_script(self, mock_uuid, mock_write_winrm_file):
953+
mock_uuid4.return_value = "37c27abd-85ff-4cb8-8d31-4e7067e145ab"
954+
955+
self.morphing_tools.register_firstboot_script(
956+
mock.sentinel.script,
957+
index=10,
958+
user_provided=True)
959+
960+
self.morphing_tools._conn.exec_ps_command.assert_called_once_with(
961+
"C:\\LocalScripts")
962+
mock_write_winrm_file.assert_called_once_with(
963+
self.morphing_tools._conn,
964+
"C:\\LocalScripts\\61-37c27abd.ps1",
965+
mock.sentinel.script)

0 commit comments

Comments
 (0)