Merge "Bootable container support"
This commit is contained in:
@@ -28,3 +28,4 @@ unzip [imagebuild]
|
||||
sudo [imagebuild]
|
||||
gawk [imagebuild]
|
||||
file [imagebuild]
|
||||
podman [imagebuild]
|
||||
|
@@ -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 = [
|
||||
|
@@ -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
|
||||
|
@@ -2739,6 +2739,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
|
||||
@@ -2815,6 +2825,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
|
||||
@@ -3381,6 +3401,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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
@@ -6916,3 +6917,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')
|
||||
|
@@ -0,0 +1,5 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Adds support to Ironic-Python-Agent for it to facilitate deployment
|
||||
of bootable containers.
|
Reference in New Issue
Block a user