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
7 changes: 3 additions & 4 deletions coriolis/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,12 +390,11 @@
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.
# Scripts that are executed when the replica VM starts for the first time.
PHASE_REPLICA_FIRST_BOOT = "replica_first_boot"

USER_SCRIPT_PHASES = [
PHASE_OSMORPHING_PRE_OS_MOUNT,
PHASE_OSMORPHING_POST_OS_MOUNT,
PHASE_REPLICA_FIRST_BOOT,
]
145 changes: 145 additions & 0 deletions coriolis/osmorphing/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,71 @@
CLOUD_INIT_SERVICE_UNIT_NAME = "cloud-init"
CLOUD_INIT_SERVICE_UNIT_NAME_FALLBACK = "cloud-init-main"

FIRST_BOOT_SCRIPT_RUNNER = """#!/bin/bash

first_error=0

function run_scripts {
script_dir=$1

for f in "$script_dir"/*.sh; do
if [ -x "$f" ]; then
Comment thread
petrutlucian94 marked this conversation as resolved.
echo "Invoking script: $f"
"$f"
rc=$?
echo "Exit code: $rc"

if [ $rc -ne 0 ] && [ $first_error -eq 0 ]; then
first_error=$rc
fi
else
echo "Ignoring script, not executable: $f"
fi
done
}

# Run Coriolis provided scripts.
run_scripts /usr/lib/coriolis/firstboot/service

# Run user provided scripts.
run_scripts /usr/lib/coriolis/firstboot/user

if [ $first_error -eq 0 ]; then
echo "All the scripts completed successfully."
echo "Creating /var/lib/coriolis/firstboot-complete"
mkdir -p /var/lib/coriolis
touch /var/lib/coriolis/firstboot-complete
else
echo "One of the scripts failed."
echo "Won't create /var/lib/coriolis/firstboot-complete"
fi

exit $first_error
"""
Comment thread
petrutlucian94 marked this conversation as resolved.

FIRST_BOOT_SCRIPT_RUNNER_PATH = "/usr/lib/coriolis/firstboot/run-firstboot.sh"
FIRST_BOOT_SYSTEMD_UNIT = """
[Unit]
Description=Coriolis replica first-boot scripts.
After=network-online.target
Wants=network-online.target
ConditionPathExists=!/var/lib/coriolis/firstboot-complete

[Service]
Type=oneshot
ExecStart=/usr/lib/coriolis/firstboot/run-firstboot.sh
RemainAfterExit=yes
Comment thread
petrutlucian94 marked this conversation as resolved.
StandardOutput=journal+console
StandardError=journal+console

[Install]
WantedBy=multi-user.target
"""
Comment thread
petrutlucian94 marked this conversation as resolved.

FIRST_BOOT_SYSTEMD_UNIT_NAME = "coriolis-firstboot.service"
FIRST_BOOT_SYSTEMD_UNIT_PATH = (
f"/etc/systemd/system/{FIRST_BOOT_SYSTEMD_UNIT_NAME}")


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

Expand Down Expand Up @@ -100,6 +165,30 @@ def get_packages(self):
def run_user_script(self, user_script):
pass

@abc.abstractmethod
def register_firstboot_script(
self,
script: str,
index: int = 0,
user_provided: bool = True,
script_filename: str | None = None,
):
"""Register a script to be executed during the first replica boot.

:param script: the script content
:param index: script execution index (0-99), used as a script filename
prefix. The scripts will be executed in alphabetic order,
the Coriolis scripts first and then user provided scripts
afterwards.
:param user_provider: whether this is a Coriolis internal script
(executed first) or user provided script.
:param script_filename: optional script filename. The index parameter
will be ignored when explicitly specifying
the filename. Coriolis internal scripts should
specify a filename to facilitate debugging.
"""
pass

@abc.abstractmethod
def pre_packages_install(self, package_names):
pass
Expand Down Expand Up @@ -719,3 +808,59 @@ def _setup_network_preservation(self, nics_info) -> None:
self._add_net_udev_rules(net_ifaces_info)

return

def register_firstboot_script(
self,
script: str,
index: int = 0,
user_provided: bool = True,
script_filename: str | None = None,
):

if not script:
LOG.debug("Empty first-boot script, skipping...")
return

if user_provided:
script_dir = "/usr/lib/coriolis/firstboot/user"
else:
script_dir = "/usr/lib/coriolis/firstboot/service"

if not script_filename:
unique_id = str(uuid.uuid4()).split("-")[0]
script_filename = f"{index:02d}-{unique_id}.sh"

script_path = os.path.join(script_dir, script_filename)

self._exec_cmd_chroot(f"mkdir -p {script_dir}")
self._write_file_sudo(script_path, script)
self._exec_cmd_chroot(f"chown root:root {script_path}")
self._exec_cmd_chroot(f"chmod 755 {script_path}")

# systemd unit used to launch first-boot scripts.
if not self._test_path(FIRST_BOOT_SYSTEMD_UNIT_PATH):
self._write_file_sudo(
FIRST_BOOT_SYSTEMD_UNIT_PATH, FIRST_BOOT_SYSTEMD_UNIT)
self._exec_cmd_chroot(
"chown root:root %s" % FIRST_BOOT_SYSTEMD_UNIT_PATH)
self._exec_cmd_chroot(
"chmod 644 %s" % FIRST_BOOT_SYSTEMD_UNIT_PATH)
wants_dir = "/etc/systemd/system/multi-user.target.wants"
self._exec_cmd_chroot("mkdir -p %s" % wants_dir)
self._exec_cmd_chroot(
"ln -sf %s %s/%s" % (
FIRST_BOOT_SYSTEMD_UNIT_PATH,
wants_dir,
FIRST_BOOT_SYSTEMD_UNIT_NAME))

# A script that iterates over "/usr/lib/coriolis/firstboot/*.sh"
# scripts and runs them.
if not self._test_path(FIRST_BOOT_SCRIPT_RUNNER_PATH):
self._write_file_sudo(
FIRST_BOOT_SCRIPT_RUNNER_PATH, FIRST_BOOT_SCRIPT_RUNNER)
self._exec_cmd_chroot(
"chown root:root %s" % FIRST_BOOT_SCRIPT_RUNNER_PATH)
self._exec_cmd_chroot(
"chmod 755 %s" % FIRST_BOOT_SCRIPT_RUNNER_PATH)

LOG.info(f"Registered first-boot script: {script_path}")
10 changes: 10 additions & 0 deletions coriolis/osmorphing/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,13 @@ def _morph_image(origin_provider, destination_provider, connection_info,

LOG.info("Post packages install")
import_os_morphing_tools.post_packages_install(packages_add)

first_boot_user_scripts = [
script["payload"] for script in user_scripts
if script["phase"] == constants.PHASE_REPLICA_FIRST_BOOT]
for script_idx, user_script in enumerate(first_boot_user_scripts):
event_manager.progress_update('Registering first-boot user script')
import_os_morphing_tools.register_firstboot_script(
user_script, script_idx, user_provided=True)
if not first_boot_user_scripts:
event_manager.progress_update('No first-boot user script specified')
48 changes: 45 additions & 3 deletions coriolis/osmorphing/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,14 +456,19 @@ def _get_cbslinit_scripts_dir(self, base_dir):

def _write_local_script(self, base_dir, script_path, priority=50):
scripts_dir = self._get_cbslinit_scripts_dir(base_dir)
script = "%s\\%d-%s" % (
remote_script_path = "%s\\%02d-%s" % (
scripts_dir, priority,
os.path.basename(script_path))

with open(script_path, 'r') as fd:
contents = fd.read()
utils.write_winrm_file(
self._conn, script, contents)
self._conn, remote_script_path, contents)

LOG.info(
"Registered first-boot Coriolis script: %s -> %s",
script_path,
remote_script_path)

def _write_cloudbase_init_conf(self, cloudbaseinit_base_dir,
local_base_dir, com_port="COM1",
Expand Down Expand Up @@ -525,7 +530,7 @@ def _write_cloudbase_init_conf(self, cloudbaseinit_base_dir,

self._write_local_script(
cloudbaseinit_base_dir, disks_script,
priority=99)
priority=10)

def _install_cloudbase_init(self, download_url,
metadata_services=None, enabled_plugins=None,
Expand Down Expand Up @@ -723,3 +728,40 @@ def uninstall_packages(self, package_names):

def post_packages_uninstall(self, package_names):
pass

def register_firstboot_script(
self,
script: str,
index: int = 0,
user_provided: bool = True,
script_filename: str | None = None,
):
if not script:
LOG.debug("Empty first-boot script, skipping...")
return

if user_provided:
# The default priority for Coriolis scripts is "50",
# some using below 50.
#
# The scripts are executed in alphabetical order, so the
# ones with a lower "priority" will be executed first.
#
# We'll bump the priority here so that user scripts will
# run after the Coriolis internal scripts.
index += 51

cbslinit_base_dir = self._get_cbslinit_base_dir()
script_dir = self._get_cbslinit_scripts_dir(cbslinit_base_dir)
if not script_filename:
unique_id = str(uuid.uuid4()).split("-")[0]
script_filename = f"{index:02d}-{unique_id}.ps1"
script_path = os.path.join(script_dir, script_filename)

self._conn.exec_ps_command(f"mkdir -Force {script_dir}")
utils.write_winrm_file(
self._conn,
script_path,
script)

LOG.info(f"Registered first-boot script: {script_path}")
56 changes: 56 additions & 0 deletions coriolis/tests/integration/deployments/test_osmorphing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
installation in the target OS.
"""

import os
import re
import uuid

from coriolis.tests.integration import base as integration_base
Expand Down Expand Up @@ -135,3 +137,57 @@ def test_os_morphing_global_script_extended_format(self):
# the replica OS disk was mounted.
self.assertNotIn(self._dst_device, pre_mounts)
self.assertIn(self._dst_device, post_mounts)

def test_os_morphing_global_script_first_boot(self):
payload = "mount > /boot_mounts"
user_scripts = {
'global': {
'linux': [
{
"phase": "replica_first_boot",
"payload": "mount > /boot_mounts",
},
],
'windows': [
{
"phase": "replica_first_boot",
"payload": "should-not-get-executed",
},
]
}
}
deployment_kwargs = {
"user_scripts": user_scripts,
}
self._execute_transfer_and_deployment(deployment_kwargs)

# TODO(lpetrut): the test import provider doesn't actually create
# replica instances (containers). If it did, we'd have no way to clean
# them up using Coriolis APIs.
#
# For this reason, we can't ensure that the first boot scripts
# actually get executed. We'll merely verify that those files
# have been injected at the expected location.
first_boot_script_dir = "usr/lib/coriolis/firstboot/user"
first_boot_scripts = test_utils.list_files_from_device(
self._dst_device, first_boot_script_dir)
if not first_boot_scripts:
raise AssertionError("Couldn't find first boot script dir.")

found = False
for file_name in first_boot_scripts:
if re.match(r"\d+-\w+\.sh", file_name):
first_boot_script_path = os.path.join(
first_boot_script_dir, file_name)
first_boot_script = test_utils.read_file_from_device(
self._dst_device,
first_boot_script_path)
if payload == first_boot_script:
found = True
if payload == "should-not-get-executed":
raise AssertionError(
"Linux instance contains Windows script.")

if not found:
raise AssertionError(
"Couldn't find the expected first boot script.")
Comment thread
petrutlucian94 marked this conversation as resolved.
15 changes: 15 additions & 0 deletions coriolis/tests/integration/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,18 @@ def read_file_from_device(device_path, rel_path):
return f.read()
finally:
_run(["umount", mount_point])


def list_files_from_device(device_path, rel_path):
"""Enumerates files from the filesystem of *device_path*.

Mounts the device read-only into a temporary directory, enumerates files,
then unmounts.
"""
with tempfile.TemporaryDirectory() as mount_point:
_run(["mount", "-o", "ro", device_path, mount_point])

try:
return os.listdir(os.path.join(mount_point, rel_path))
finally:
_run(["umount", mount_point])
12 changes: 12 additions & 0 deletions coriolis/tests/osmorphing/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def setUp(self):
"phase": constants.PHASE_OSMORPHING_POST_OS_MOUNT,
"payload": "post-os-mount-script",
},
{
"phase": constants.PHASE_REPLICA_FIRST_BOOT,
"payload": "first-boot-script",
},
]

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

def register_firstboot_script(
self,
script: str,
index: int = 0,
user_provided=True,
):
pass

@mock.patch.object(manager.osmount_factory, 'get_os_mount_tools')
@mock.patch.object(manager.events, 'EventManager')
@mock.patch.object(manager, 'run_os_detect')
Expand Down
Loading
Loading