Merge "Bootable container support"

This commit is contained in:
Zuul
2025-02-10 19:26:34 +00:00
committed by Gerrit Code Review
7 changed files with 717 additions and 0 deletions

View File

@@ -28,3 +28,4 @@ unzip [imagebuild]
sudo [imagebuild]
gawk [imagebuild]
file [imagebuild]
podman [imagebuild]

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')

View File

@@ -0,0 +1,5 @@
---
features:
- |
Adds support to Ironic-Python-Agent for it to facilitate deployment
of bootable containers.