From 1508cc4cd0951a49c9605272e69ecd2571395107 Mon Sep 17 00:00:00 2001 From: Julia Kreger Date: Fri, 24 Jan 2025 16:45:45 -0800 Subject: [PATCH] Bootable container support Adds support for bootable containers to be deployed by the agent. Related: https://review.opendev.org/c/openstack/ironic/+/937897 Change-Id: I66cb37d117d2afc335f015fb1fc31bdbd5c3cee5 --- bindep.txt | 1 + ironic_python_agent/config.py | 5 + ironic_python_agent/extensions/standby.py | 239 +++++++++++ ironic_python_agent/hardware.py | 47 +++ .../tests/unit/extensions/test_standby.py | 398 ++++++++++++++++++ .../tests/unit/test_hardware.py | 22 + ...dd-support-for-bootc-70b8a4546b176ab4.yaml | 5 + 7 files changed, 717 insertions(+) create mode 100644 releasenotes/notes/add-support-for-bootc-70b8a4546b176ab4.yaml diff --git a/bindep.txt b/bindep.txt index aa7a17ad8..57e783ab4 100644 --- a/bindep.txt +++ b/bindep.txt @@ -28,3 +28,4 @@ unzip [imagebuild] sudo [imagebuild] gawk [imagebuild] file [imagebuild] +podman [imagebuild] diff --git a/ironic_python_agent/config.py b/ironic_python_agent/config.py index 129d92eb3..883d55297 100644 --- a/ironic_python_agent/config.py +++ b/ironic_python_agent/config.py @@ -388,6 +388,11 @@ cli_opts = [ 'image validation logic will fail the deployment ' 'process. This check is skipped if deep image ' 'inspection is disabled.'), + cfg.BoolOpt('disable_bootc_deploy', + default=False, + help='This disables bootc deployment methods in the ramdisk ' + 'because the bootc command inside of the ramdisk ' + 'comes from the supplied image to be deployed.'), ] disk_utils_opts = [ diff --git a/ironic_python_agent/extensions/standby.py b/ironic_python_agent/extensions/standby.py index d7ede534d..4f7eb1a7d 100644 --- a/ironic_python_agent/extensions/standby.py +++ b/ironic_python_agent/extensions/standby.py @@ -12,8 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import errno import hashlib +import json import os import re import tempfile @@ -1109,3 +1111,240 @@ class StandbyExtension(base.BaseAgentExtension): LOG.error(msg) if CONF.fail_if_clock_not_set or not ignore_errors: raise errors.ClockSyncError(msg) + + @base.async_command('execute_bootc_install') + def execute_bootc_install(self, image_source, instance_info={}, + pull_secret=None, configdrive=None): + """Asynchronously prepares specified image on local OS install device. + + Identifies target disk device to deploy onto, and extracts necessary + configuration data to trigger podman, triggers podman, verifies + partitioning changes were made, and finally executes configuration + drive write-out. + + :param image_source: The OCI Container registry URL supplied by Ironic. + :param instance_info: An Ironic Node's instance_info filed for user + requested specific configuration details be extracted. + :param pull_secret: The user requested or system required pull secret + to authenticate to remote container image registries. + :param configdrive: The user requested configuration drive content + supplied by Ironic's step execution command. + + :raises: ImageDownloadError if the image download encounters an error. + :raises: ImageChecksumError if the checksum of the local image does not + match the checksum as reported by glance in image_info. + :raises: ImageWriteError if writing the image fails. + :raises: InstanceDeployFailure if failed to create config drive. + large to store on the given device. + """ + LOG.debug('Preparing container %s for bootc.', image_source) + if CONF.disable_bootc_deploy: + LOG.error('A bootc based deployment was requested for %s, ' + 'however bootc based deployment is disabled.', + image_source) + raise errors.CommandExecutionError( + details=("The bootc deploy interface is administratively " + "disable. Deployment cannot proceed.")) + device = hardware.dispatch_to_managers('get_os_install_device', + permit_refresh=True) + authorized_keys = instance_info.get('bootc_authorized_keys', None) + tpm2_luks = instance_info.get('bootc_tpm2_luks', False) + self._download_container_and_bootc_install(image_source, device, + pull_secret, tpm2_luks, + authorized_keys) + + _validate_partitioning(device) + + # For partition images the configdrive creation is taken care by + # partition_utils.work_on_disk(), invoked from either + # _write_partition_image or _cache_and_write_image above. + # Handle whole disk images explicitly now. + if configdrive: + partition_utils.create_config_drive_partition('local', + device, + configdrive) + + msg = f'Container image ({image_source}) written to device {device}' + LOG.info(msg) + return msg + + def _download_container_and_bootc_install(self, image_source, + device, pull_secret, + tpm2_luks, authorized_keys): + """Downloads container and triggers bootc install. + + :param image_source: The user requested image_source to + deploy to the hard disk. + :param device: The device to deploy to. + :param pull_secret: A pull secret to interact with a remote + container image registry. + :param tpm2_luks: Boolean value if LUKS should be requested. + :param authorized_keys: The authorized keys string data + to supply to podman, if applicable. + :raises: ImageDownloadError If the downloaded container + lacks the ``bootc`` command. + :raises: ImageWriteError If the execution of podman fails. + """ + # First, disable pivot_root in podman because it cannot be + # performed on top of a ramdisk filesystem. + self._write_no_pivot_root() + + # Identify the URL, specifically so we drop + url = urlparse.urlparse(image_source) + + # This works because the path is maintained. + container_url = url.netloc + url.path + + if pull_secret: + self._write_container_auth(pull_secret, url.netloc) + + # Get the disk size, and convert it to megabtyes. + disk_size = disk_utils.get_dev_byte_size(device) // 1024 // 1024 + + # Ensure we leave enough space for a configuration drive, + # and ESP partition. + # NOTE(TheJulia): bootc leans towards a 512 MB EFI partition. + disk_size = disk_size - 768 + # Convert from a float to string. + disk_size = str(disk_size) + + # Determine the status of selinux. + selinux = False + try: + stdout, _ = utils.execute("getenforce", use_standard_locale=True) + if stdout.startswith('Enforcing'): + selinux = True + except (processutils.ProcessExecutionError, + errors.CommandExecutionError, + OSError): + pass + + # Execute Podman to run bootc from the container. + # + # This has to run as a privileged operation, mapping the container + # assets from the runtime to inside of the container environment, + # and pass the device through. + # + # As for bootc itself... + # --skip-fetch-check disables an internal check to bootc to make sure + # it can retrieve updates from the remote registry, which is fine if + # credentials are already in the container or we embed the credentials, + # but that is not the best idea. + # --disable-selinux is alternatively needed if selinux is *not* + # enabled on the host. + command = [ + 'podman', + '--log-level=debug', + 'run', '--rm', '--privileged', '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', + # By default, podman's retry starts at 3s and extends + # expentionally, which can lead to podman appearing + # to hang when downloading. This pins it so it just + # retires in relatively short order. + '--retry-delay=5s', + ] + if pull_secret: + command.append('--authfile=/root/.config/containers/auth.json') + if authorized_keys: + # NOTE(TheJulia): Bandit flags on this, but we need a folder which + # should exist in the container *and* locally to the ramdisk. + # As such, flagging with nosec. + command.extend(['-v', '/tmp:/tmp']) # nosec B108 + if selinux: + command.extend([ + '--security-opt', 'label=type:unconfined_t' + ]) + command.extend([ + container_url, + 'bootc', 'install', 'to-disk', + '--wipe', '--skip-fetch-check', + '--root-size=' + disk_size + 'M' + ]) + if tpm2_luks: + command.append('--block-setup=tpm2-luks') + if authorized_keys: + key_file = self._write_authorized_keys(authorized_keys) + command.append(f'--root-ssh-authorized-keys={key_file}') + + if not selinux: + # For SELinux to be applied, per the bootc docs, you must have + # SELinux enabled on the host system. + command.append('--disable-selinux') + + command.append(device) + + try: + stdout, stderr = utils.execute(*command, use_standard_locale=True) + except processutils.ProcessExecutionError as e: + LOG.debug('Failed to execute podman: %s', e) + raise errors.ImageWriteError(device, e.exit_code, e.stdout, + e.stderr) + for output in [stdout, stderr]: + if 'executable file `bootc` not found' in output: + # This is the case where the container doesn't actually + # support bootc, because it lacks the bootc tool. + # This should be stderr, but appears in stdout. Check both + # just on the safe side. + raise errors.ImageDownloadError( + image_source, + "Container does not contain the required bootc binary " + "and thus cannot be deployed." + ) + + def _write_no_pivot_root(self): + """Writes a podman no-pivot configuration.""" + # This method writes a configuration to tell podman + # to *don't* attempt to pivot_root on the ramdisk, because + # it won't work. In essence, just setting the environment, + # to actually execute a container. + path = '/etc/containers/containers.conf.d' + os.makedirs(path, exist_ok=True) + file_path = os.path.join(path, '01-ipa.conf') + file_content = '[engine]\nno_pivot_root = true\n' + with open(file_path, 'w') as file: + file.write(file_content) + + def _write_container_auth(self, pull_secret, netloc): + """Write authentication configuration for container registry auth. + + :param pull_secret: The authorization pull secret string for + interacting with a remote container registry. + :param netloc: The FQDN, or network location portion of the URL + used to access the container registry. + """ + # extract secret + decoded_pull_secret = base64.standard_b64decode( + pull_secret + ).decode() + + # Generate a dict which will emulate our container auth + # configuration. + auth_dict = { + "auths": {netloc: {"auth": decoded_pull_secret}}} + + # Make the folders to $HOME/.config/containers/auth.json + # which would normally be generated by podman login, but + # we don't need to actually do that as we have a secret. + # Default to root, as we don't launch IPA with a HOME + # folder in most cases. + home = '/root' + folder = os.path.join(home, '.config/containers') + os.makedirs(folder, mode=0o700, exist_ok=True) + auth_path = os.path.join(folder, 'auth.json') + + # Save the pull secret + with open(auth_path, 'w') as file: + json.dump(auth_dict, file) + + def _write_authorized_keys(self, authorized_keys): + """Write a temporary authorized keys file for bootc use.""" + # Write authorized_keys content to a temporary file + # on the temporary folder path structure which can be + # accessed by podman. On linux in our ramdisks, this + # should always be /tmp. We then return the absolute + # file path for podman to leverage. + fd, file_path = tempfile.mkstemp(text=True) + os.write(fd, authorized_keys.encode()) + os.close(fd) + return file_path diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py index 62b02989e..c5ee09f7d 100644 --- a/ironic_python_agent/hardware.py +++ b/ironic_python_agent/hardware.py @@ -2717,6 +2717,16 @@ class GenericHardwareManager(HardwareManager): 'reboot_requested': False, 'argsinfo': inject_files.ARGSINFO, }, + { + 'step': 'execute_bootc_install', + # NOTE(TheJulia): Similar to write_image above, this step + # has to be called directly by a driver to represent the + # flow, hence no priority here and realistically it also + # doesn't really matter. + 'priority': 0, + 'interface': 'deploy', + 'reboot_requested': False, + }, ] # TODO(TheJulia): There has to be a better way, we should @@ -2786,6 +2796,16 @@ class GenericHardwareManager(HardwareManager): 'reboot_requested': False, 'argsinfo': inject_files.ARGSINFO, }, + { + 'step': 'execute_bootc_install', + # NOTE(TheJulia): Similar to write_image above, this step + # has to be called directly by a driver to represent the + # flow, hence no priority here and realistically it also + # doesn't really matter. + 'priority': 0, + 'interface': 'deploy', + 'reboot_requested': False, + }, ] # TODO(TheJulia): Consider erase_devices and friends... return service_steps @@ -3352,6 +3372,33 @@ class GenericHardwareManager(HardwareManager): # The result is asynchronous, wait here. return cmd.wait() + def execute_bootc_install(self, node, ports, image_source, configdrive, + oci_pull_secret): + """Deploy a container using bootc install. + + Downloads, runs, and leverages bootc install to deploy the desired + container to the disk using bootc and writes any configuration + drive to the disk if necessary. + + :param node: A dictionary of the node object + :param ports: A list of dictionaries containing information + of ports for the node + :param image_info: Image information dictionary. + :param configdrive: A string containing the location of the config + drive as a URL OR the contents (as gzip/base64) + of the configdrive. Optional, defaults to None. + :param oci_pull_secret: The base64 encoded pull secret to utilize + to retrieve the user requested container. + """ + ext = ext_base.get_extension('standby') + cmd = ext.execute_bootc_install( + image_source=image_source, + instance_info=node.get('instance_info'), + pull_secret=oci_pull_secret, + configdrive=configdrive) + # The result is asynchronous, wait here. + return cmd.wait() + def generate_tls_certificate(self, ip_address): """Generate a TLS certificate for the IP address.""" return tls_utils.generate_tls_certificate(ip_address) diff --git a/ironic_python_agent/tests/unit/extensions/test_standby.py b/ironic_python_agent/tests/unit/extensions/test_standby.py index 5cdbc5b1b..0e5cd0c5c 100644 --- a/ironic_python_agent/tests/unit/extensions/test_standby.py +++ b/ironic_python_agent/tests/unit/extensions/test_standby.py @@ -23,6 +23,7 @@ from oslo_config import cfg from oslo_utils import units import requests +from ironic_python_agent import disk_utils from ironic_python_agent import errors from ironic_python_agent.extensions import standby from ironic_python_agent import hardware @@ -1683,6 +1684,403 @@ class TestStandbyExtension(base.IronicAgentTest): self.assertEqual(expected_uuid, work_on_disk_mock.return_value) self.assertIsNone(node_uuid) + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) + @mock.patch.object(partition_utils, 'create_config_drive_partition', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_download_container_and_bootc_install', + autospec=True) + @mock.patch.object(standby, '_validate_partitioning', + autospec=True) + def test_execute_bootc_install( + self, + validate_mock, + install_mock, + config_drive_mock, + dispatch_mock): + fake_instance_info = {'bootc_authorized_keys': 'pubkey', + 'bootc_tpm2_luks': True} + dispatch_mock.return_value = '/dev/fake' + res = self.agent_extension.execute_bootc_install( + image_source='oci://foo', + instance_info=fake_instance_info, + pull_secret='secret', + configdrive='config!') + dispatch_mock.assert_called_once_with('get_os_install_device', + permit_refresh=True) + config_drive_mock.assert_called_once_with('local', '/dev/fake', + 'config!') + install_mock.assert_called_once_with(mock.ANY, 'oci://foo', + '/dev/fake', 'secret', + True, 'pubkey') + expected = ('execute_bootc_install: Container image (oci://foo) ' + 'written to device /dev/fake') + self.assertEqual(expected, res.command_result['result']) + self.assertEqual('SUCCEEDED', res.command_status) + + @mock.patch.object(standby.LOG, 'error', autospec=True) + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) + @mock.patch.object(partition_utils, 'create_config_drive_partition', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_download_container_and_bootc_install', + autospec=True) + @mock.patch.object(standby, '_validate_partitioning', + autospec=True) + def test_execute_bootc_install_disabled( + self, + validate_mock, + install_mock, + config_drive_mock, + dispatch_mock, + error_mock): + CONF.set_override('disable_bootc_deploy', True) + fake_instance_info = {'bootc_authorized_keys': 'pubkey', + 'bootc_tpm2_luks': True} + dispatch_mock.return_value = '/dev/fake' + async_res = self.agent_extension.execute_bootc_install( + image_source='oci://foo', + instance_info=fake_instance_info, + pull_secret='secret', + configdrive='config!') + dispatch_mock.assert_not_called() + config_drive_mock.assert_not_called() + install_mock.assert_not_called() + async_res.join() + self.assertEqual('FAILED', async_res.command_status) + error_mock.assert_called_once_with( + 'A bootc based deployment was requested for %s, ' + 'however bootc based deployment is disabled.', + 'oci://foo') + + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) + @mock.patch.object(partition_utils, 'create_config_drive_partition', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_download_container_and_bootc_install', + autospec=True) + @mock.patch.object(standby, '_validate_partitioning', + autospec=True) + def test_execute_bootc_install_minimal( + self, + validate_mock, + install_mock, + config_drive_mock, + dispatch_mock): + fake_instance_info = {} + dispatch_mock.return_value = '/dev/fake' + + res = self.agent_extension.execute_bootc_install( + image_source='oci://foo', + instance_info=fake_instance_info, + pull_secret=None, + configdrive=None) + dispatch_mock.assert_called_once_with('get_os_install_device', + permit_refresh=True) + config_drive_mock.assert_not_called() + install_mock.assert_called_once_with(mock.ANY, 'oci://foo', + '/dev/fake', None, + False, None) + expected = ('execute_bootc_install: Container image (oci://foo) ' + 'written to device /dev/fake') + self.assertEqual(expected, res.command_result['result']) + self.assertEqual('SUCCEEDED', res.command_status) + + @mock.patch('ironic_python_agent.utils.execute', autospec=True) + @mock.patch.object(disk_utils, 'get_dev_byte_size', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_no_pivot_root', + autospec=True) + def test__download_container_and_bootc_install( + self, + no_pivot_mock, + write_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): + get_size_mock.return_value = 2000000000 + execute_mock.side_effect = iter([ + (('Enforcing\n'), ()), + ((), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' + self.agent_extension._download_container_and_bootc_install( + 'oci://foo/container', '/dev/fake', 'secret', False, 'keys!') + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_called_once_with(mock.ANY, + 'secret', + 'foo') + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', + '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', '--retry-delay=5s', + '--authfile=/root/.config/containers/auth.json', + '-v', '/tmp:/tmp', '--security-opt', + 'label=type:unconfined_t', 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=1139M', + '--root-ssh-authorized-keys=/tmp/fake/file', + '/dev/fake', use_standard_locale=True) + ]) + + @mock.patch('ironic_python_agent.utils.execute', autospec=True) + @mock.patch.object(disk_utils, 'get_dev_byte_size', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_no_pivot_root', + autospec=True) + def test__download_container_and_bootc_install_luks( + self, + no_pivot_mock, + write_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): + get_size_mock.return_value = 2000000000 + execute_mock.side_effect = iter([ + (('Enforcing\n'), ()), + ((), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' + self.agent_extension._download_container_and_bootc_install( + 'oci://foo/container', '/dev/fake', 'secret', True, 'keys!') + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_called_once_with(mock.ANY, + 'secret', + 'foo') + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', + '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', '--retry-delay=5s', + '--authfile=/root/.config/containers/auth.json', + '-v', '/tmp:/tmp', '--security-opt', + 'label=type:unconfined_t', 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=1139M', + '--block-setup=tpm2-luks', + '--root-ssh-authorized-keys=/tmp/fake/file', + '/dev/fake', use_standard_locale=True) + ]) + + @mock.patch('ironic_python_agent.utils.execute', autospec=True) + @mock.patch.object(disk_utils, 'get_dev_byte_size', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_no_pivot_root', + autospec=True) + def test__download_container_and_bootc_install_no_selinux_keys_auth( + self, + no_pivot_mock, + write_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): + get_size_mock.return_value = 15000000000 + execute_mock.side_effect = iter([ + OSError(), + ((), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' + + self.agent_extension._download_container_and_bootc_install( + 'oci://foo/container', '/dev/fake', None, False, None) + + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', + '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', '--retry-delay=5s', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', + '--disable-selinux', + '/dev/fake', use_standard_locale=True) + ]) + + @mock.patch('ironic_python_agent.utils.execute', autospec=True) + @mock.patch.object(disk_utils, 'get_dev_byte_size', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_no_pivot_root', + autospec=True) + def test__download_container_and_bootc_install_errors_no_bootc( + self, + no_pivot_mock, + write_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): + get_size_mock.return_value = 15000000000 + execute_mock.side_effect = iter([ + OSError(), + (('Error executable file `bootc` not found and'), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' + + self.assertRaisesRegex( + errors.ImageDownloadError, + ('Container does not contain the required bootc binary ' + 'and thus cannot be deployed.'), + self.agent_extension._download_container_and_bootc_install, + 'oci://foo/container', '/dev/fake', None, False, None) + + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', + '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', '--retry-delay=5s', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', + '--disable-selinux', + '/dev/fake', use_standard_locale=True) + ]) + + @mock.patch('ironic_python_agent.utils.execute', autospec=True) + @mock.patch.object(disk_utils, 'get_dev_byte_size', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, + '_write_no_pivot_root', + autospec=True) + def test__download_container_and_bootc_install_podman_errors( + self, + no_pivot_mock, + write_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): + get_size_mock.return_value = 15000000000 + execute_mock.side_effect = iter([ + OSError(), + processutils.ProcessExecutionError()]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' + self.assertRaisesRegex( + errors.ImageWriteError, + ('Error writing image to device: Writing image to device ' + '/dev/fake failed with'), + self.agent_extension._download_container_and_bootc_install, + 'oci://foo/container', '/dev/fake', None, False, None) + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', + '--pid=host', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', '--retry-delay=5s', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', + '--disable-selinux', + '/dev/fake', use_standard_locale=True) + ]) + + @mock.patch.object(os, 'makedirs', autospec=True) + @mock.patch('builtins.open', new_callable=mock.mock_open()) + def test__write_no_pivot_root(self, mock_open, mkdir_mock): + self.agent_extension._write_no_pivot_root() + mkdir_mock.assert_called_once_with( + '/etc/containers/containers.conf.d', + exist_ok=True) + mock_open.assert_called_once_with( + '/etc/containers/containers.conf.d/01-ipa.conf', 'w') + mock_write = mock_open.return_value.__enter__.return_value.write + mock_write.assert_called_once_with( + '[engine]\nno_pivot_root = true\n') + + @mock.patch.object(os, 'makedirs', autospec=True) + @mock.patch('builtins.open', new_callable=mock.mock_open()) + def test__write_container_auth(self, mock_open, mkdir_mock): + self.agent_extension._write_container_auth( + b'c2VjcmV0', 'foo.tld') + mkdir_mock.assert_called_once_with( + '/root/.config/containers', + mode=0o700, + exist_ok=True) + mock_open.assert_called_once_with( + '/root/.config/containers/auth.json', 'w') + mock_write = mock_open.return_value.__enter__.return_value.write + # NOTE(TheJulia): This is a side effect of using json.dump to make + # the actual write call, and python internally does buffered io which + # should concatenate the writes together appropriately as needed. + mock_write.assert_has_calls([ + mock.call('{'), + mock.call('"auths"'), + mock.call(': '), + mock.call('{'), + mock.call('"foo.tld"'), + mock.call(': '), + mock.call('{'), + mock.call('"auth"'), + mock.call(': '), + mock.call('"secret"'), + mock.call('}'), + mock.call('}'), + mock.call('}') + ]) + + @mock.patch.object(os, 'close', autospec=True) + @mock.patch.object(os, 'write', autospec=True) + @mock.patch.object(tempfile, 'mkstemp', autospec=True) + def test__write_authorized_keys(self, mock_temp, mock_write, mock_close): + mock_temp.return_value = ('fd', '/tmp/path') + self.agent_extension._write_authorized_keys('the-key') + mock_temp.assert_called_once_with(text=True) + mock_write.assert_called_once_with('fd', b'the-key') + mock_close.assert_called_once() + @mock.patch('hashlib.new', autospec=True) @mock.patch('requests.get', autospec=True) diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py index 395b3c48f..f56b202a2 100644 --- a/ironic_python_agent/tests/unit/test_hardware.py +++ b/ironic_python_agent/tests/unit/test_hardware.py @@ -34,6 +34,7 @@ from stevedore import extension from ironic_python_agent import disk_utils from ironic_python_agent import efi_utils from ironic_python_agent import errors +from ironic_python_agent.extensions import base as ext_base from ironic_python_agent import hardware from ironic_python_agent import netutils from ironic_python_agent import raid_utils @@ -6861,3 +6862,24 @@ class TestFullSync(base.IronicAgentTest): mock.call('blockdev', '--flushbufs', '/dev/sda'), mock.call('blockdev', '--flushbufs', '/dev/nvme0n1'), ]) + + +class TestExecuteBootCInstall(base.IronicAgentTest): + + def setUp(self): + super().setUp() + self.hardware = hardware.GenericHardwareManager() + + @mock.patch.object(ext_base, 'get_extension', autospec=True) + def test_execute_bootc_install(self, mock_get_ext): + ext = mock.Mock() + node = {'name': 'node-0', 'instance_info': {'foo': 'bar'}} + mock_get_ext.return_value = ext + self.hardware.execute_bootc_install(node, [], 'oci://foo', + None, 'secret') + ext.execute_bootc_install.assert_called_once_with( + image_source='oci://foo', + instance_info={'foo': 'bar'}, + pull_secret='secret', + configdrive=None) + mock_get_ext.assert_called_once_with('standby') diff --git a/releasenotes/notes/add-support-for-bootc-70b8a4546b176ab4.yaml b/releasenotes/notes/add-support-for-bootc-70b8a4546b176ab4.yaml new file mode 100644 index 000000000..db4c32dd5 --- /dev/null +++ b/releasenotes/notes/add-support-for-bootc-70b8a4546b176ab4.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Adds support to Ironic-Python-Agent for it to facilitate deployment + of bootable containers.