Files
ironic-python-agent/ironic_python_agent/tests/unit/test_qemu_img.py
Jay Faulkner e303a369dc Inspect non-raw images for safety
When IPA gets a non-raw image, it performs an on-the-fly conversion
using qemu-img convert, as well as running qemu-img frequently to get
basic information about the image before validating it.

Now, we ensure that before any qemu-img calls are made, that we have
inspected the image for safety and pass through the detected format.

If given a disk_format=raw image and image streaming is enabled
(default), we retain the existing behavior of not inspecting it in
any way and streaming it bit-perfect to the device. In this case, we
never use qemu-based tools on the image at all.

If given a disk_format=raw image and image streaming is disabled, this
change fixes a bug where the image may have been converted if it was not
actually raw in the first place. We now stream these bit-perfect to the
device.

Adds two config options:
- [DEFAULT]/disable_deep_image_inspection, which can be set to "True" in
  order to disable all security features. Do not do this.
- [DEFAULT]/permitted_image_formats, default raw,qcow2, for image types
  IPA should accept.

Both of these configuration options are wired up to be set by the lookup
data returned by Ironic at lookup time.

This uses a image format inspection module imported from Nova; this
inspector will eventually live in oslo.utils, at which point we'll
migrate our usage of the inspector to it.

Closes-Bug: #2071740
Change-Id: I5254b80717cb5a7f9084e3eff32a00b968f987b7
2024-09-04 09:11:28 -07:00

333 lines
14 KiB
Python

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
from unittest import mock
from ironic_lib.tests import base
from ironic_lib import utils
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_utils import imageutils
from ironic_python_agent import errors
from ironic_python_agent import qemu_img
CONF = cfg.CONF
class ImageInfoTestCase(base.IronicLibTestCase):
@mock.patch.object(os.path, 'exists', return_value=False, autospec=True)
def test_image_info_path_doesnt_exist_disabled(self, path_exists_mock):
CONF.set_override('disable_deep_image_inspection', True)
self.assertRaises(FileNotFoundError, qemu_img.image_info, 'noimg')
path_exists_mock.assert_called_once_with('noimg')
@mock.patch.object(utils, 'execute', return_value=('out', 'err'),
autospec=True)
@mock.patch.object(imageutils, 'QemuImgInfo', autospec=True)
@mock.patch.object(os.path, 'exists', return_value=True, autospec=True)
def test_image_info_path_exists_disabled(self, path_exists_mock,
image_info_mock, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
qemu_img.image_info('img')
path_exists_mock.assert_called_once_with('img')
execute_mock.assert_called_once_with(
['env', 'LC_ALL=C', 'LANG=C', 'qemu-img', 'info', 'img',
'--output=json'], prlimit=mock.ANY)
image_info_mock.assert_called_once_with('out', format='json')
@mock.patch.object(utils, 'execute', return_value=('out', 'err'),
autospec=True)
@mock.patch.object(imageutils, 'QemuImgInfo', autospec=True)
@mock.patch.object(os.path, 'exists', return_value=True, autospec=True)
def test_image_info_path_exists_safe(
self, path_exists_mock, image_info_mock, execute_mock):
qemu_img.image_info('img', source_format='qcow2')
path_exists_mock.assert_called_once_with('img')
execute_mock.assert_called_once_with(
['env', 'LC_ALL=C', 'LANG=C', 'qemu-img', 'info', 'img',
'--output=json', '-f', 'qcow2'],
prlimit=mock.ANY
)
image_info_mock.assert_called_once_with('out', format='json')
@mock.patch.object(utils, 'execute', return_value=('out', 'err'),
autospec=True)
@mock.patch.object(imageutils, 'QemuImgInfo', autospec=True)
@mock.patch.object(os.path, 'exists', return_value=True, autospec=True)
def test_image_info_path_exists_unsafe(
self, path_exists_mock, image_info_mock, execute_mock):
# Call without source_format raises
self.assertRaises(errors.InvalidImage,
qemu_img.image_info, 'img')
# safety valve! Don't run **anything** against the image without
# source_format unless specifically permitted
path_exists_mock.assert_not_called()
execute_mock.assert_not_called()
image_info_mock.assert_not_called()
class ConvertImageTestCase(base.IronicLibTestCase):
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_disabled(self, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
qemu_img.convert_image('source', 'dest', 'out_format')
execute_mock.assert_called_once_with(
'qemu-img', 'convert', '-O',
'out_format', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_flags_disabled(self, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
qemu_img.convert_image('source', 'dest', 'out_format',
cache='directsync', out_of_order=True,
sparse_size='0')
execute_mock.assert_called_once_with(
'qemu-img', 'convert', '-O',
'out_format', '-t', 'directsync',
'-S', '0', '-W', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries_disabled(self, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
ret_err = 'qemu: qemu_thread_create: Resource temporarily unavailable'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
('', ''),
]
qemu_img.convert_image('source', 'dest', 'out_format')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries_alternate_error_disabled(self, exe_mock):
CONF.set_override('disable_deep_image_inspection', True)
ret_err = 'Failed to allocate memory: Cannot allocate memory\n'
exe_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
('', ''),
]
qemu_img.convert_image('source', 'dest', 'out_format')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
exe_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries_and_fails_disabled(self, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
ret_err = 'qemu: qemu_thread_create: Resource temporarily unavailable'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err),
]
self.assertRaises(processutils.ProcessExecutionError,
qemu_img.convert_image,
'source', 'dest', 'out_format')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_just_fails_disabled(self, execute_mock):
CONF.set_override('disable_deep_image_inspection', True)
ret_err = 'Aliens'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err),
]
self.assertRaises(processutils.ProcessExecutionError,
qemu_img.convert_image,
'source', 'dest', 'out_format')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image(self, execute_mock):
qemu_img.convert_image('source', 'dest', 'out_format',
source_format='fmt')
execute_mock.assert_called_once_with(
'qemu-img', 'convert', '-O',
'out_format', '-f', 'fmt',
'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_flags(self, execute_mock):
qemu_img.convert_image('source', 'dest', 'out_format',
cache='directsync', out_of_order=True,
sparse_size='0', source_format='fmt')
execute_mock.assert_called_once_with(
'qemu-img', 'convert', '-O',
'out_format', '-t', 'directsync',
'-S', '0', '-f', 'fmt', '-W', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries(self, execute_mock):
ret_err = 'qemu: qemu_thread_create: Resource temporarily unavailable'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
('', ''),
]
qemu_img.convert_image('source', 'dest', 'out_format',
source_format='fmt')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', '-f', 'fmt', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries_alternate_error(self, execute_mock):
ret_err = 'Failed to allocate memory: Cannot allocate memory\n'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
('', ''),
]
qemu_img.convert_image('source', 'dest', 'out_format',
source_format='fmt')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', '-f', 'fmt', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_retries_and_fails(self, execute_mock):
ret_err = 'qemu: qemu_thread_create: Resource temporarily unavailable'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err), ('', ''),
processutils.ProcessExecutionError(stderr=ret_err),
]
self.assertRaises(processutils.ProcessExecutionError,
qemu_img.convert_image,
'source', 'dest', 'out_format', source_format='fmt')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', '-f', 'fmt', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
mock.call('sync'),
convert_call,
mock.call('sync'),
convert_call,
])
@mock.patch.object(utils, 'execute', autospec=True)
def test_convert_image_just_fails(self, execute_mock):
ret_err = 'Aliens'
execute_mock.side_effect = [
processutils.ProcessExecutionError(stderr=ret_err),
]
self.assertRaises(processutils.ProcessExecutionError,
qemu_img.convert_image,
'source', 'dest', 'out_format', source_format='fmt')
convert_call = mock.call('qemu-img', 'convert', '-O',
'out_format', '-f', 'fmt', 'source', 'dest',
run_as_root=False,
prlimit=mock.ANY,
use_standard_locale=True,
env_variables={'MALLOC_ARENA_MAX': '3'})
execute_mock.assert_has_calls([
convert_call,
])