Merge "Bootable container support"
This commit is contained in:
@@ -28,3 +28,4 @@ unzip [imagebuild]
|
|||||||
sudo [imagebuild]
|
sudo [imagebuild]
|
||||||
gawk [imagebuild]
|
gawk [imagebuild]
|
||||||
file [imagebuild]
|
file [imagebuild]
|
||||||
|
podman [imagebuild]
|
||||||
|
@@ -388,6 +388,11 @@ cli_opts = [
|
|||||||
'image validation logic will fail the deployment '
|
'image validation logic will fail the deployment '
|
||||||
'process. This check is skipped if deep image '
|
'process. This check is skipped if deep image '
|
||||||
'inspection is disabled.'),
|
'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 = [
|
disk_utils_opts = [
|
||||||
|
@@ -12,8 +12,10 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import base64
|
||||||
import errno
|
import errno
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -1109,3 +1111,240 @@ class StandbyExtension(base.BaseAgentExtension):
|
|||||||
LOG.error(msg)
|
LOG.error(msg)
|
||||||
if CONF.fail_if_clock_not_set or not ignore_errors:
|
if CONF.fail_if_clock_not_set or not ignore_errors:
|
||||||
raise errors.ClockSyncError(msg)
|
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,
|
'reboot_requested': False,
|
||||||
'argsinfo': inject_files.ARGSINFO,
|
'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
|
# TODO(TheJulia): There has to be a better way, we should
|
||||||
@@ -2815,6 +2825,16 @@ class GenericHardwareManager(HardwareManager):
|
|||||||
'reboot_requested': False,
|
'reboot_requested': False,
|
||||||
'argsinfo': inject_files.ARGSINFO,
|
'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...
|
# TODO(TheJulia): Consider erase_devices and friends...
|
||||||
return service_steps
|
return service_steps
|
||||||
@@ -3381,6 +3401,33 @@ class GenericHardwareManager(HardwareManager):
|
|||||||
# The result is asynchronous, wait here.
|
# The result is asynchronous, wait here.
|
||||||
return cmd.wait()
|
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):
|
def generate_tls_certificate(self, ip_address):
|
||||||
"""Generate a TLS certificate for the IP address."""
|
"""Generate a TLS certificate for the IP address."""
|
||||||
return tls_utils.generate_tls_certificate(ip_address)
|
return tls_utils.generate_tls_certificate(ip_address)
|
||||||
|
@@ -23,6 +23,7 @@ from oslo_config import cfg
|
|||||||
from oslo_utils import units
|
from oslo_utils import units
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from ironic_python_agent import disk_utils
|
||||||
from ironic_python_agent import errors
|
from ironic_python_agent import errors
|
||||||
from ironic_python_agent.extensions import standby
|
from ironic_python_agent.extensions import standby
|
||||||
from ironic_python_agent import hardware
|
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.assertEqual(expected_uuid, work_on_disk_mock.return_value)
|
||||||
self.assertIsNone(node_uuid)
|
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('hashlib.new', autospec=True)
|
||||||
@mock.patch('requests.get', 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 disk_utils
|
||||||
from ironic_python_agent import efi_utils
|
from ironic_python_agent import efi_utils
|
||||||
from ironic_python_agent import errors
|
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 hardware
|
||||||
from ironic_python_agent import netutils
|
from ironic_python_agent import netutils
|
||||||
from ironic_python_agent import raid_utils
|
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/sda'),
|
||||||
mock.call('blockdev', '--flushbufs', '/dev/nvme0n1'),
|
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