Merge "Revert "Fix vmedia network config drive handling""

This commit is contained in:
Zuul 2023-11-30 15:14:20 +00:00 committed by Gerrit Code Review
commit beccfe8c92
4 changed files with 66 additions and 274 deletions

View File

@ -30,9 +30,8 @@ def run():
"""Entrypoint for IronicPythonAgent.""" """Entrypoint for IronicPythonAgent."""
# NOTE(dtantsur): this must happen very early of the files from # NOTE(dtantsur): this must happen very early of the files from
# /etc/ironic-python-agent.d won't be loaded # /etc/ironic-python-agent.d won't be loaded
vmedia_configuration = utils.copy_config_from_vmedia() utils.copy_config_from_vmedia()
if vmedia_configuration:
utils.trigger_glean_network_refresh()
log.register_options(CONF) log.register_options(CONF)
CONF(args=sys.argv[1:]) CONF(args=sys.argv[1:])
# Debug option comes from oslo.log, allow overriding it via kernel cmdline # Debug option comes from oslo.log, allow overriding it via kernel cmdline

View File

@ -883,7 +883,7 @@ class TestClockSyncUtils(ironic_agent_base.IronicAgentTest):
@mock.patch.object(shutil, 'copy', autospec=True) @mock.patch.object(shutil, 'copy', autospec=True)
@mock.patch.object(ironic_utils, 'mounted', autospec=True) @mock.patch.object(ironic_utils, 'mounted', autospec=True)
@mock.patch.object(utils, 'execute', autospec=True) @mock.patch.object(utils, 'execute', autospec=True)
class TestConfigFromVmedia(testtools.TestCase): class TestCopyConfigFromVmedia(testtools.TestCase):
def test_vmedia_found_not_booted_from_vmedia( def test_vmedia_found_not_booted_from_vmedia(
self, mock_execute, mock_mount, mock_copy, self, mock_execute, mock_mount, mock_copy,
@ -909,107 +909,45 @@ class TestConfigFromVmedia(testtools.TestCase):
mock_check_vmedia.assert_not_called() mock_check_vmedia.assert_not_called()
self.assertFalse(mock_booted_from_vmedia.called) self.assertFalse(mock_booted_from_vmedia.called)
@mock.patch.object(os.path, 'ismount', autospec=True)
def test_no_files( def test_no_files(
self, mock_ismount, mock_execute, mock_mount, mock_copy, self, mock_execute, mock_mount, mock_copy,
mock_find_device, mock_check_vmedia, mock_booted_from_vmedia): mock_find_device, mock_check_vmedia, mock_booted_from_vmedia):
mock_ismount.return_value = False
mock_booted_from_vmedia.return_value = True mock_booted_from_vmedia.return_value = True
temp_path = tempfile.mkdtemp() temp_path = tempfile.mkdtemp()
self.addCleanup(lambda: shutil.rmtree(temp_path)) self.addCleanup(lambda: shutil.rmtree(temp_path))
mock_execute.side_effect = [('/mnt/config', ()), mock_execute.side_effect = processutils.ProcessExecutionError
((), ())]
mock_find_device.return_value = '/dev/something' mock_find_device.return_value = '/dev/something'
mock_mount.return_value.__enter__.return_value = temp_path mock_mount.return_value.__enter__.return_value = temp_path
utils.copy_config_from_vmedia() utils.copy_config_from_vmedia()
mock_mount.assert_called_once_with('/dev/something') mock_mount.assert_called_once_with('/dev/something')
mock_execute.assert_has_calls([ mock_execute.assert_called_once_with('findmnt', '-n', '-oTARGET',
mock.call('findmnt', '-n', '-oTARGET', '/dev/something'), '/dev/something')
mock.call('umount', '/mnt/config', run_as_root=True)])
self.assertEqual(2, mock_execute.call_count)
mock_copy.assert_not_called() mock_copy.assert_not_called()
self.assertTrue(mock_booted_from_vmedia.called) self.assertTrue(mock_booted_from_vmedia.called)
@mock.patch.object(os.path, 'ismount', autospec=True)
def test_mounted_no_files( def test_mounted_no_files(
self, mock_ismount, mock_execute, mock_mount, mock_copy, self, mock_execute, mock_mount, mock_copy,
mock_find_device, mock_check_vmedia, mock_booted_from_vmedia): mock_find_device, mock_check_vmedia, mock_booted_from_vmedia):
mock_ismount.side_effect = [True, False]
mock_booted_from_vmedia.return_value = True mock_booted_from_vmedia.return_value = True
mock_execute.side_effect = [('/some/path', ''), mock_execute.return_value = '/some/path', ''
((), ()),
((), ())]
mock_find_device.side_effect = ('/dev/something', '')
mock_find_device.return_value = '/dev/something' mock_find_device.return_value = '/dev/something'
utils.copy_config_from_vmedia() utils.copy_config_from_vmedia()
mock_execute.assert_has_calls([
mock.call('findmnt', '-n', '-oTARGET', '/dev/something'),
mock.call('umount', '/some/path', run_as_root=True),
mock.call('umount', '/mnt/config', run_as_root=True)])
self.assertEqual(3, mock_execute.call_count)
mock_copy.assert_not_called()
mock_mount.assert_called_once_with('/dev/something')
self.assertTrue(mock_booted_from_vmedia.called)
@mock.patch.object(os.path, 'ismount', autospec=True)
@mock.patch.object(os, 'makedirs', autospec=True)
def test_copy(
self, mock_makedirs, mock_ismount, mock_execute, mock_mount,
mock_copy, mock_find_device, mock_check_vmedia,
mock_booted_from_vmedia):
mock_ismount.return_value = False
mock_booted_from_vmedia.return_value = True
mock_find_device.return_value = '/dev/something'
mock_execute.side_effect = processutils.ProcessExecutionError('meow')
path = tempfile.mkdtemp()
self.addCleanup(lambda: shutil.rmtree(path))
def _fake_mount(dev):
self.assertEqual('/dev/something', dev)
# NOTE(dtantsur): makedirs is mocked
os.mkdir(os.path.join(path, 'etc'))
os.mkdir(os.path.join(path, 'etc', 'ironic-python-agent'))
os.mkdir(os.path.join(path, 'etc', 'ironic-python-agent.d'))
with open(os.path.join(path, 'not copied'), 'wt') as fp:
fp.write('not copied')
with open(os.path.join(path, 'etc', 'ironic-python-agent',
'ironic.crt'), 'wt') as fp:
fp.write('I am a cert')
with open(os.path.join(path, 'etc', 'ironic-python-agent.d',
'ironic.conf'), 'wt') as fp:
fp.write('I am a config')
return mock.MagicMock(**{'__enter__.return_value': path})
mock_find_device.return_value = '/dev/something'
mock_mount.side_effect = _fake_mount
utils.copy_config_from_vmedia()
mock_makedirs.assert_has_calls([
mock.call('/etc/ironic-python-agent', exist_ok=True),
mock.call('/etc/ironic-python-agent.d', exist_ok=True),
], any_order=True)
mock_mount.assert_called_once_with('/dev/something')
mock_copy.assert_has_calls([
mock.call(mock.ANY, '/etc/ironic-python-agent/ironic.crt'),
mock.call(mock.ANY, '/etc/ironic-python-agent.d/ironic.conf'),
], any_order=True)
self.assertTrue(mock_booted_from_vmedia.called)
mock_execute.assert_called_once_with( mock_execute.assert_called_once_with(
'findmnt', '-n', '-oTARGET', '/dev/something') 'findmnt', '-n', '-oTARGET', '/dev/something')
mock_copy.assert_not_called()
mock_mount.assert_not_called()
self.assertTrue(mock_booted_from_vmedia.called)
@mock.patch.object(os.path, 'ismount', autospec=True)
@mock.patch.object(os, 'makedirs', autospec=True) @mock.patch.object(os, 'makedirs', autospec=True)
def test_copy_mounted( def test_copy(
self, mock_makedirs, mock_ismount, mock_execute, mock_mount, self, mock_makedirs, mock_execute, mock_mount, mock_copy,
mock_copy, mock_find_device, mock_check_vmedia, mock_find_device, mock_check_vmedia, mock_booted_from_vmedia):
mock_booted_from_vmedia):
mock_ismount.side_effect = [True, True, False]
mock_booted_from_vmedia.return_value = True mock_booted_from_vmedia.return_value = True
mock_find_device.side_effect = ('/dev/something', '') mock_find_device.return_value = '/dev/something'
mock_execute.side_effect = processutils.ProcessExecutionError("")
path = tempfile.mkdtemp() path = tempfile.mkdtemp()
self.addCleanup(lambda: shutil.rmtree(path)) self.addCleanup(lambda: shutil.rmtree(path))
@ -1031,10 +969,6 @@ class TestConfigFromVmedia(testtools.TestCase):
mock_find_device.return_value = '/dev/something' mock_find_device.return_value = '/dev/something'
mock_mount.side_effect = _fake_mount mock_mount.side_effect = _fake_mount
mock_execute.side_effect = [(path, ''),
((), ()),
((), ()),
((), ())]
utils.copy_config_from_vmedia() utils.copy_config_from_vmedia()
@ -1042,16 +976,52 @@ class TestConfigFromVmedia(testtools.TestCase):
mock.call('/etc/ironic-python-agent', exist_ok=True), mock.call('/etc/ironic-python-agent', exist_ok=True),
mock.call('/etc/ironic-python-agent.d', exist_ok=True), mock.call('/etc/ironic-python-agent.d', exist_ok=True),
], any_order=True) ], any_order=True)
mock_execute.assert_has_calls([ mock_mount.assert_called_once_with('/dev/something')
mock.call('findmnt', '-n', '-oTARGET', '/dev/something'),
mock.call('umount', mock.ANY, run_as_root=True),
mock.call('umount', '/mnt/config', run_as_root=True),
mock.call('umount', '/mnt/config', run_as_root=True)])
mock_copy.assert_has_calls([ mock_copy.assert_has_calls([
mock.call(mock.ANY, '/etc/ironic-python-agent/ironic.crt'), mock.call(mock.ANY, '/etc/ironic-python-agent/ironic.crt'),
mock.call(mock.ANY, '/etc/ironic-python-agent.d/ironic.conf'), mock.call(mock.ANY, '/etc/ironic-python-agent.d/ironic.conf'),
], any_order=True) ], any_order=True)
mock_mount.assert_called_once_with('/dev/something') self.assertTrue(mock_booted_from_vmedia.called)
@mock.patch.object(os, 'makedirs', autospec=True)
def test_copy_mounted(
self, mock_makedirs, mock_execute, mock_mount,
mock_copy, mock_find_device, mock_check_vmedia,
mock_booted_from_vmedia):
mock_booted_from_vmedia.return_value = True
mock_find_device.return_value = '/dev/something'
path = tempfile.mkdtemp()
self.addCleanup(lambda: shutil.rmtree(path))
# NOTE(dtantsur): makedirs is mocked
os.mkdir(os.path.join(path, 'etc'))
os.mkdir(os.path.join(path, 'etc', 'ironic-python-agent'))
os.mkdir(os.path.join(path, 'etc', 'ironic-python-agent.d'))
with open(os.path.join(path, 'not copied'), 'wt') as fp:
fp.write('not copied')
with open(os.path.join(path, 'etc', 'ironic-python-agent',
'ironic.crt'), 'wt') as fp:
fp.write('I am a cert')
with open(os.path.join(path, 'etc', 'ironic-python-agent.d',
'ironic.conf'), 'wt') as fp:
fp.write('I am a config')
mock_execute.return_value = path, ''
mock_find_device.return_value = '/dev/something'
utils.copy_config_from_vmedia()
mock_makedirs.assert_has_calls([
mock.call('/etc/ironic-python-agent', exist_ok=True),
mock.call('/etc/ironic-python-agent.d', exist_ok=True),
], any_order=True)
mock_execute.assert_called_once_with(
'findmnt', '-n', '-oTARGET', '/dev/something')
mock_copy.assert_has_calls([
mock.call(mock.ANY, '/etc/ironic-python-agent/ironic.crt'),
mock.call(mock.ANY, '/etc/ironic-python-agent.d/ironic.conf'),
], any_order=True)
mock_mount.assert_not_called()
self.assertTrue(mock_booted_from_vmedia.called) self.assertTrue(mock_booted_from_vmedia.called)
@ -1152,73 +1122,6 @@ class TestCheckVirtualMedia(ironic_agent_base.IronicAgentTest):
'/dev/sdh') '/dev/sdh')
@mock.patch.object(shutil, 'copy', autospec=True)
@mock.patch.object(os, 'makedirs', autospec=True)
@mock.patch.object(utils, '_determine_networking_path', autospec=True)
@mock.patch.object(utils, 'execute', autospec=True)
class TestNetworkConfigRefresh(ironic_agent_base.IronicAgentTest):
def test_tinycore(self, mock_exec, mock_path, mock_makedirs,
mock_copy):
mock_path.return_value = 'tinycore'
utils.trigger_glean_network_refresh()
self.assertEqual(1, mock_path.call_count)
mock_exec.assert_has_calls([
mock.call('glean', '--distro', 'tinycore', run_as_root=True),
mock.call('/bin/sh', '/opt/network.sh', run_as_root=True)])
mock_makedirs.assert_called_once_with('/mnt/config/openstack/latest/',
exist_ok=True)
def test_execution_error_is_handled(self, mock_exec, mock_path,
mock_makedirs, mock_copy):
mock_path.return_value = 'tinycore'
mock_exec.side_effect = OSError()
utils.trigger_glean_network_refresh()
self.assertEqual(1, mock_path.call_count)
mock_exec.assert_called_once_with(
'glean', '--distro', 'tinycore', run_as_root=True)
mock_makedirs.assert_called_once_with('/mnt/config/openstack/latest/',
exist_ok=True)
mock_copy.assert_called_once_with(
'/etc/ironic-python-agent.d/network_data.json',
'/mnt/config/openstack/latest/network_data.json')
def test_networkd(self, mock_exec, mock_path,
mock_makedirs, mock_copy):
mock_path.return_value = 'networkd'
utils.trigger_glean_network_refresh()
self.assertEqual(1, mock_path.call_count)
mock_exec.assert_has_calls([
mock.call('glean', '--distro', 'networkd', run_as_root=True),
mock.call('systemctl', 'restart', 'systemd-networkd.service',
run_as_root=True)])
mock_makedirs.assert_called_once_with('/mnt/config/openstack/latest/',
exist_ok=True)
mock_copy.assert_called_once_with(
'/etc/ironic-python-agent.d/network_data.json',
'/mnt/config/openstack/latest/network_data.json')
def test_networkmanager(self, mock_exec, mock_path,
mock_makedirs, mock_copy):
mock_path.return_value = 'networkmanager'
mock_exec.side_effect = [('', ''),
OSError(),
('', '')]
utils.trigger_glean_network_refresh()
self.assertEqual(1, mock_path.call_count)
mock_exec.assert_has_calls([
mock.call('glean', '--use-nm', run_as_root=True),
mock.call('systemctl', 'restart', 'NetworkManager',
run_as_root=True),
mock.call('systemctl', 'restart', 'NetworkManager.service',
run_as_root=True)])
mock_makedirs.assert_called_once_with('/mnt/config/openstack/latest/',
exist_ok=True)
mock_copy.assert_called_once_with(
'/etc/ironic-python-agent.d/network_data.json',
'/mnt/config/openstack/latest/network_data.json')
class TestCheckEarlyLogging(ironic_agent_base.IronicAgentTest): class TestCheckEarlyLogging(ironic_agent_base.IronicAgentTest):
@mock.patch.object(utils, 'LOG', autospec=True) @mock.patch.object(utils, 'LOG', autospec=True)

View File

@ -21,7 +21,6 @@ import glob
import io import io
import json import json
import os import os
import platform
import re import re
import shutil import shutil
import subprocess import subprocess
@ -34,7 +33,6 @@ from oslo_concurrency import processutils
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log as logging from oslo_log import log as logging
from oslo_utils import units from oslo_utils import units
import psutil
import requests import requests
import tenacity import tenacity
@ -128,7 +126,6 @@ def _find_vmedia_device_by_labels(labels):
for device in ironic_utils.parse_device_tags(lsblk_output): for device in ironic_utils.parse_device_tags(lsblk_output):
for label in labels: for label in labels:
if label.upper() == device['LABEL'].upper(): if label.upper() == device['LABEL'].upper():
_early_log('Found vmedia candidate %s', device['KNAME'])
candidates.append(device['KNAME']) candidates.append(device['KNAME'])
for candidate in candidates: for candidate in candidates:
@ -213,21 +210,13 @@ def _copy_config_from(path):
msg = ("Unable to copy vmedia configuration %s to %s: %s" msg = ("Unable to copy vmedia configuration %s to %s: %s"
% (src, dest, exc)) % (src, dest, exc))
raise errors.VirtualMediaBootError(msg) raise errors.VirtualMediaBootError(msg)
network_data_path = os.path.join(
path, 'openstack/latest/network_data.json')
if os.path.isfile(network_data_path):
dest_path = '/etc/ironic-python-agent%s' % ext
_early_log('Copying network_data.json to %s', dest_path)
os.makedirs(dest_path, exist_ok=True)
shutil.copy(network_data_path, os.path.join(dest_path,
'network_data.json'))
def _find_mount_point(device): def _find_mount_point(device):
try: try:
path, _e = execute('findmnt', '-n', '-oTARGET', device) path, _e = execute('findmnt', '-n', '-oTARGET', device)
except (processutils.ProcessExecutionError, OSError): except processutils.ProcessExecutionError:
return None return
else: else:
return path.strip() return path.strip()
@ -244,7 +233,6 @@ def _check_vmedia_device(vmedia_device_file):
valid. valid.
""" """
try: try:
_early_log('Checking device %s vmedia status.', vmedia_device_file)
output, _e = execute('lsblk', '-n', '-s', '-P', '-b', output, _e = execute('lsblk', '-n', '-s', '-P', '-b',
'-oKNAME,TRAN,TYPE,SIZE', '-oKNAME,TRAN,TYPE,SIZE',
vmedia_device_file) vmedia_device_file)
@ -255,8 +243,6 @@ def _check_vmedia_device(vmedia_device_file):
try: try:
for device in ironic_utils.parse_device_tags(output): for device in ironic_utils.parse_device_tags(output):
if device['TYPE'] == 'part': if device['TYPE'] == 'part':
# This would exclude any existing configuration drive written
# by ironic previously.
_early_log('Excluding device %s from virtual media' _early_log('Excluding device %s from virtual media'
'consideration as it is a partition.', 'consideration as it is a partition.',
device['KNAME']) device['KNAME'])
@ -267,7 +253,7 @@ def _check_vmedia_device(vmedia_device_file):
# registered for the scsi transport and thus type used. # registered for the scsi transport and thus type used.
# This will most likely be a qemu driven testing VM, # This will most likely be a qemu driven testing VM,
# or an older machine where SCSI transport is directly # or an older machine where SCSI transport is directly
# used to convey in a virtual media device. # used to convey in a virtual
return True return True
if device['TYPE'] == 'disk' and device['TRAN'] == 'usb': if device['TYPE'] == 'disk' and device['TRAN'] == 'usb':
# We know from experience on HPE machines, with ilo4/5, we see # We know from experience on HPE machines, with ilo4/5, we see
@ -320,9 +306,6 @@ def copy_config_from_vmedia():
"""Copies any configuration from a virtual media device. """Copies any configuration from a virtual media device.
Copies files under /etc/ironic-python-agent and /etc/ironic-python-agent.d. Copies files under /etc/ironic-python-agent and /etc/ironic-python-agent.d.
:returns: True when we successfully copied content from the virtual media
device. Otherwise None.
""" """
vmedia_device_file = _find_vmedia_device_by_labels( vmedia_device_file = _find_vmedia_device_by_labels(
['config-2', 'vmedia_boot_iso']) ['config-2', 'vmedia_boot_iso'])
@ -336,84 +319,10 @@ def copy_config_from_vmedia():
# Determine the device # Determine the device
mounted = _find_mount_point(vmedia_device_file) mounted = _find_mount_point(vmedia_device_file)
if mounted: if mounted:
# Unmount if already mounted by something like glean/cloud init. _copy_config_from(mounted)
execute("umount", mounted, run_as_root=True)
with ironic_utils.mounted(vmedia_device_file) as vmedia_mount_point:
_copy_config_from(vmedia_mount_point)
# Finally, check mount and ensure unmounted before proceeding.
# NOTE(TheJulia): This is a reserved path by convention for configuration
# drive handling. Since you *can* stack multiple devices, we need to make
# sure no other tooling left anything in an unclean state.
while os.path.ismount("/mnt/config"):
try:
execute("umount", "/mnt/config", run_as_root=True)
_early_log("Sucessfully unmounted /mnt/config as an orphaned "
"source of configuration.")
except (OSError, processutils.ProcessExecutionError):
_early_log("We failed to umount /mnt/config. This may be fatal "
"if virtual media is in use for configuration.")
return False
return True
def _determine_networking_path():
os_ver = platform.uname().version.lower()
if 'tinycore' in os_ver:
# Compact CI oriented IPA Image.
return 'tinycore'
if True in ['networkd' in x.name().lower()
for x in psutil.process_iter(['name'])]:
# Basically, this would be Ubuntu AFAIK.
return 'networkd'
else: else:
# This is fairly safe to assume at this point. with ironic_utils.mounted(vmedia_device_file) as vmedia_mount_point:
return 'networkmanager' _copy_config_from(vmedia_mount_point)
def trigger_glean_network_refresh():
"""Trigger procesing and refresh of network configuration."""
# NOTE(TheJulia): Ironic explicitly doesn't support cloud-init
# as the consumer of this information in ramdisks. This stance
# may change in the future, but as of the beginning of the
# Caracal development cycle, cloud-init is uninstalled
# by ironic-python-agent-builder.
glean_read_path = '/mnt/config/openstack/latest/'
# NOTE(TheJulia) Exist_okay because we might end up re-triggering down
# this path if the agent restarts due to a transient failure.
os.makedirs(glean_read_path, exist_ok=True)
backup_copy = '/etc/ironic-python-agent.d/network_data.json'
network_data = os.path.join(glean_read_path, 'network_data.json')
shutil.copy(backup_copy, network_data)
_early_log('Working to apply network configuration refresh.')
network_type = _determine_networking_path()
# TODO(TheJulia): Check if python-glean is even in the $PATH.
# Two uniform actions, run glean, then restart all networking.
try:
if network_type == 'networkmanager':
# network manager present
execute('glean', '--use-nm', run_as_root=True)
try:
execute('systemctl', 'restart', 'NetworkManager',
run_as_root=True)
except Exception:
# Inconsistent naming across distributions.
execute('systemctl', 'restart', 'NetworkManager.service',
run_as_root=True)
if network_type == 'networkd':
execute('glean', '--distro', 'networkd', run_as_root=True)
execute('systemctl', 'restart', 'systemd-networkd.service',
run_as_root=True)
if network_type == 'tinycore':
execute('glean', '--distro', 'tinycore', run_as_root=True)
# While a shell script, glean doesn't set this file to
# be executable once it writes it.
execute('/bin/sh', '/opt/network.sh', run_as_root=True)
except Exception as e:
_early_log('Unable to execute configuration refresh for '
'configuration drive data. Error: %s' % e)
def _get_cached_params(): def _get_cached_params():

View File

@ -1,19 +0,0 @@
---
fixes:
- |
Fixes an issue with the agent where the presence of a configuration drive
from a prior node deployment could prevent networking configuration from
being determined and loaded in DHCP-less use cases.
The agent now attempts to identify the appropriate configuration drive
networking data source utilizing the existing device identification
process, and then triggers glean configuration to be re-generated,
after which networking is restarted. For more information, please
view `bug 2032377 <https://bugs.launchpad.net/ironic/+bug/2032377>`_.
security:
- |
The ironic agent is now able to re-assert networking configuration
in DHCP-less modes should a prior node's ``config drive`` be
discovered as readable. Normally this issue is not a security
issue as long as ``ironic-python-agent-builder`` is utilized.
Ironic's agent automatically exlcudes possible configuration
drive copies from previously deployed baremetal nodes.