Merge "Check images with format_inspector for safety" into unmaintained/yoga
This commit is contained in:
@@ -416,6 +416,16 @@ compatibility on the destination host during live migration.
|
|||||||
help="""
|
help="""
|
||||||
When this is enabled, it will skip version-checking of hypervisors
|
When this is enabled, it will skip version-checking of hypervisors
|
||||||
during live migration.
|
during live migration.
|
||||||
|
"""),
|
||||||
|
cfg.BoolOpt(
|
||||||
|
'disable_deep_image_inspection',
|
||||||
|
default=False,
|
||||||
|
help="""
|
||||||
|
This disables the additional deep image inspection that the compute node does
|
||||||
|
when downloading from glance. This includes backing-file, data-file, and
|
||||||
|
known-features detection *before* passing the image to qemu-img. Generally,
|
||||||
|
this inspection should be enabled for maximum safety, but this workaround
|
||||||
|
option allows disabling it if there is a compatibility concern.
|
||||||
"""),
|
"""),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
889
nova/image/format_inspector.py
Normal file
889
nova/image/format_inspector.py
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
# Copyright 2020 Red Hat, Inc
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
This is a python implementation of virtual disk format inspection routines
|
||||||
|
gathered from various public specification documents, as well as qemu disk
|
||||||
|
driver code. It attempts to store and parse the minimum amount of data
|
||||||
|
required, and in a streaming-friendly manner to collect metadata about
|
||||||
|
complex-format images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from oslo_log import log as logging
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def chunked_reader(fileobj, chunk_size=512):
|
||||||
|
while True:
|
||||||
|
chunk = fileobj.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureRegion(object):
|
||||||
|
"""Represents a region of a file we want to capture.
|
||||||
|
|
||||||
|
A region of a file we want to capture requires a byte offset into
|
||||||
|
the file and a length. This is expected to be used by a data
|
||||||
|
processing loop, calling capture() with the most recently-read
|
||||||
|
chunk. This class handles the task of grabbing the desired region
|
||||||
|
of data across potentially multiple fractional and unaligned reads.
|
||||||
|
|
||||||
|
:param offset: Byte offset into the file starting the region
|
||||||
|
:param length: The length of the region
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, offset, length):
|
||||||
|
self.offset = offset
|
||||||
|
self.length = length
|
||||||
|
self.data = b''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete(self):
|
||||||
|
"""Returns True when we have captured the desired data."""
|
||||||
|
return self.length == len(self.data)
|
||||||
|
|
||||||
|
def capture(self, chunk, current_position):
|
||||||
|
"""Process a chunk of data.
|
||||||
|
|
||||||
|
This should be called for each chunk in the read loop, at least
|
||||||
|
until complete returns True.
|
||||||
|
|
||||||
|
:param chunk: A chunk of bytes in the file
|
||||||
|
:param current_position: The position of the file processed by the
|
||||||
|
read loop so far. Note that this will be
|
||||||
|
the position in the file *after* the chunk
|
||||||
|
being presented.
|
||||||
|
"""
|
||||||
|
read_start = current_position - len(chunk)
|
||||||
|
if (read_start <= self.offset <= current_position or
|
||||||
|
self.offset <= read_start <= (self.offset + self.length)):
|
||||||
|
if read_start < self.offset:
|
||||||
|
lead_gap = self.offset - read_start
|
||||||
|
else:
|
||||||
|
lead_gap = 0
|
||||||
|
self.data += chunk[lead_gap:]
|
||||||
|
self.data = self.data[:self.length]
|
||||||
|
|
||||||
|
|
||||||
|
class ImageFormatError(Exception):
|
||||||
|
"""An unrecoverable image format error that aborts the process."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TraceDisabled(object):
|
||||||
|
"""A logger-like thing that swallows tracing when we do not want it."""
|
||||||
|
|
||||||
|
def debug(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
info = debug
|
||||||
|
warning = debug
|
||||||
|
error = debug
|
||||||
|
|
||||||
|
|
||||||
|
class FileInspector(object):
|
||||||
|
"""A stream-based disk image inspector.
|
||||||
|
|
||||||
|
This base class works on raw images and is subclassed for more
|
||||||
|
complex types. It is to be presented with the file to be examined
|
||||||
|
one chunk at a time, during read processing and will only store
|
||||||
|
as much data as necessary to determine required attributes of
|
||||||
|
the file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tracing=False):
|
||||||
|
self._total_count = 0
|
||||||
|
|
||||||
|
# NOTE(danms): The logging in here is extremely verbose for a reason,
|
||||||
|
# but should never really be enabled at that level at runtime. To
|
||||||
|
# retain all that work and assist in future debug, we have a separate
|
||||||
|
# debug flag that can be passed from a manual tool to turn it on.
|
||||||
|
if tracing:
|
||||||
|
self._log = logging.getLogger(str(self))
|
||||||
|
else:
|
||||||
|
self._log = TraceDisabled()
|
||||||
|
self._capture_regions = {}
|
||||||
|
|
||||||
|
def _capture(self, chunk, only=None):
|
||||||
|
for name, region in self._capture_regions.items():
|
||||||
|
if only and name not in only:
|
||||||
|
continue
|
||||||
|
if not region.complete:
|
||||||
|
region.capture(chunk, self._total_count)
|
||||||
|
|
||||||
|
def eat_chunk(self, chunk):
|
||||||
|
"""Call this to present chunks of the file to the inspector."""
|
||||||
|
pre_regions = set(self._capture_regions.keys())
|
||||||
|
|
||||||
|
# Increment our position-in-file counter
|
||||||
|
self._total_count += len(chunk)
|
||||||
|
|
||||||
|
# Run through the regions we know of to see if they want this
|
||||||
|
# data
|
||||||
|
self._capture(chunk)
|
||||||
|
|
||||||
|
# Let the format do some post-read processing of the stream
|
||||||
|
self.post_process()
|
||||||
|
|
||||||
|
# Check to see if the post-read processing added new regions
|
||||||
|
# which may require the current chunk.
|
||||||
|
new_regions = set(self._capture_regions.keys()) - pre_regions
|
||||||
|
if new_regions:
|
||||||
|
self._capture(chunk, only=new_regions)
|
||||||
|
|
||||||
|
def post_process(self):
|
||||||
|
"""Post-read hook to process what has been read so far.
|
||||||
|
|
||||||
|
This will be called after each chunk is read and potentially captured
|
||||||
|
by the defined regions. If any regions are defined by this call,
|
||||||
|
those regions will be presented with the current chunk in case it
|
||||||
|
is within one of the new regions.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def region(self, name):
|
||||||
|
"""Get a CaptureRegion by name."""
|
||||||
|
return self._capture_regions[name]
|
||||||
|
|
||||||
|
def new_region(self, name, region):
|
||||||
|
"""Add a new CaptureRegion by name."""
|
||||||
|
if self.has_region(name):
|
||||||
|
# This is a bug, we tried to add the same region twice
|
||||||
|
raise ImageFormatError('Inspector re-added region %s' % name)
|
||||||
|
self._capture_regions[name] = region
|
||||||
|
|
||||||
|
def has_region(self, name):
|
||||||
|
"""Returns True if named region has been defined."""
|
||||||
|
return name in self._capture_regions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
"""Returns True if the file appears to be the expected format."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
"""Returns the virtual size of the disk image, or zero if unknown."""
|
||||||
|
return self._total_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actual_size(self):
|
||||||
|
"""Returns the total size of the file, usually smaller than
|
||||||
|
virtual_size. NOTE: this will only be accurate if the entire
|
||||||
|
file is read and processed.
|
||||||
|
"""
|
||||||
|
return self._total_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete(self):
|
||||||
|
"""Returns True if we have all the information needed."""
|
||||||
|
return all(r.complete for r in self._capture_regions.values())
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""The string name of this file format."""
|
||||||
|
return 'raw'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_info(self):
|
||||||
|
"""Return info on amount of data held in memory for auditing.
|
||||||
|
|
||||||
|
This is a dict of region:sizeinbytes items that the inspector
|
||||||
|
uses to examine the file.
|
||||||
|
"""
|
||||||
|
return {name: len(region.data) for name, region in
|
||||||
|
self._capture_regions.items()}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename):
|
||||||
|
"""Read as much of a file as necessary to complete inspection.
|
||||||
|
|
||||||
|
NOTE: Because we only read as much of the file as necessary, the
|
||||||
|
actual_size property will not reflect the size of the file, but the
|
||||||
|
amount of data we read before we satisfied the inspector.
|
||||||
|
|
||||||
|
Raises ImageFormatError if we cannot parse the file.
|
||||||
|
"""
|
||||||
|
inspector = cls()
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
for chunk in chunked_reader(f):
|
||||||
|
inspector.eat_chunk(chunk)
|
||||||
|
if inspector.complete:
|
||||||
|
# No need to eat any more data
|
||||||
|
break
|
||||||
|
if not inspector.complete or not inspector.format_match:
|
||||||
|
raise ImageFormatError('File is not in requested format')
|
||||||
|
return inspector
|
||||||
|
|
||||||
|
def safety_check(self):
|
||||||
|
"""Perform some checks to determine if this file is safe.
|
||||||
|
|
||||||
|
Returns True if safe, False otherwise. It may raise ImageFormatError
|
||||||
|
if safety cannot be guaranteed because of parsing or other errors.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# The qcow2 format consists of a big-endian 72-byte header, of which
|
||||||
|
# only a small portion has information we care about:
|
||||||
|
#
|
||||||
|
# Dec Hex Name
|
||||||
|
# 0 0x00 Magic 4-bytes 'QFI\xfb'
|
||||||
|
# 4 0x04 Version (uint32_t, should always be 2 for modern files)
|
||||||
|
# . . .
|
||||||
|
# 8 0x08 Backing file offset (uint64_t)
|
||||||
|
# 24 0x18 Size in bytes (unint64_t)
|
||||||
|
# . . .
|
||||||
|
# 72 0x48 Incompatible features bitfield (6 bytes)
|
||||||
|
#
|
||||||
|
# https://gitlab.com/qemu-project/qemu/-/blob/master/docs/interop/qcow2.txt
|
||||||
|
class QcowInspector(FileInspector):
|
||||||
|
"""QEMU QCOW2 Format
|
||||||
|
|
||||||
|
This should only require about 32 bytes of the beginning of the file
|
||||||
|
to determine the virtual size, and 104 bytes to perform the safety check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BF_OFFSET = 0x08
|
||||||
|
BF_OFFSET_LEN = 8
|
||||||
|
I_FEATURES = 0x48
|
||||||
|
I_FEATURES_LEN = 8
|
||||||
|
I_FEATURES_DATAFILE_BIT = 3
|
||||||
|
I_FEATURES_MAX_BIT = 4
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super(QcowInspector, self).__init__(*a, **k)
|
||||||
|
self.new_region('header', CaptureRegion(0, 512))
|
||||||
|
|
||||||
|
def _qcow_header_data(self):
|
||||||
|
magic, version, bf_offset, bf_sz, cluster_bits, size = (
|
||||||
|
struct.unpack('>4sIQIIQ', self.region('header').data[:32]))
|
||||||
|
return magic, size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_header(self):
|
||||||
|
return self.region('header').complete
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return 0
|
||||||
|
if not self.format_match:
|
||||||
|
return 0
|
||||||
|
magic, size = self._qcow_header_data()
|
||||||
|
return size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return False
|
||||||
|
magic, size = self._qcow_header_data()
|
||||||
|
return magic == b'QFI\xFB'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_backing_file(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return None
|
||||||
|
if not self.format_match:
|
||||||
|
return False
|
||||||
|
bf_offset_bytes = self.region('header').data[
|
||||||
|
self.BF_OFFSET:self.BF_OFFSET + self.BF_OFFSET_LEN]
|
||||||
|
# nonzero means "has a backing file"
|
||||||
|
bf_offset, = struct.unpack('>Q', bf_offset_bytes)
|
||||||
|
return bf_offset != 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_unknown_features(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return None
|
||||||
|
if not self.format_match:
|
||||||
|
return False
|
||||||
|
i_features = self.region('header').data[
|
||||||
|
self.I_FEATURES:self.I_FEATURES + self.I_FEATURES_LEN]
|
||||||
|
|
||||||
|
# This is the maximum byte number we should expect any bits to be set
|
||||||
|
max_byte = self.I_FEATURES_MAX_BIT // 8
|
||||||
|
|
||||||
|
# The flag bytes are in big-endian ordering, so if we process
|
||||||
|
# them in index-order, they're reversed
|
||||||
|
for i, byte_num in enumerate(reversed(range(self.I_FEATURES_LEN))):
|
||||||
|
if byte_num == max_byte:
|
||||||
|
# If we're in the max-allowed byte, allow any bits less than
|
||||||
|
# the maximum-known feature flag bit to be set
|
||||||
|
allow_mask = ((1 << self.I_FEATURES_MAX_BIT) - 1)
|
||||||
|
elif byte_num > max_byte:
|
||||||
|
# If we're above the byte with the maximum known feature flag
|
||||||
|
# bit, then we expect all zeroes
|
||||||
|
allow_mask = 0x0
|
||||||
|
else:
|
||||||
|
# Any earlier-than-the-maximum byte can have any of the flag
|
||||||
|
# bits set
|
||||||
|
allow_mask = 0xFF
|
||||||
|
|
||||||
|
if i_features[i] & ~allow_mask:
|
||||||
|
LOG.warning('Found unknown feature bit in byte %i: %s/%s',
|
||||||
|
byte_num, bin(i_features[byte_num] & ~allow_mask),
|
||||||
|
bin(allow_mask))
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data_file(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return None
|
||||||
|
if not self.format_match:
|
||||||
|
return False
|
||||||
|
i_features = self.region('header').data[
|
||||||
|
self.I_FEATURES:self.I_FEATURES + self.I_FEATURES_LEN]
|
||||||
|
|
||||||
|
# First byte of bitfield, which is i_features[7]
|
||||||
|
byte = self.I_FEATURES_LEN - 1 - self.I_FEATURES_DATAFILE_BIT // 8
|
||||||
|
# Third bit of bitfield, which is 0x04
|
||||||
|
bit = 1 << (self.I_FEATURES_DATAFILE_BIT - 1 % 8)
|
||||||
|
return bool(i_features[byte] & bit)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'qcow2'
|
||||||
|
|
||||||
|
def safety_check(self):
|
||||||
|
return (not self.has_backing_file and
|
||||||
|
not self.has_data_file and
|
||||||
|
not self.has_unknown_features)
|
||||||
|
|
||||||
|
|
||||||
|
# The VHD (or VPC as QEMU calls it) format consists of a big-endian
|
||||||
|
# 512-byte "footer" at the beginning of the file with various
|
||||||
|
# information, most of which does not matter to us:
|
||||||
|
#
|
||||||
|
# Dec Hex Name
|
||||||
|
# 0 0x00 Magic string (8-bytes, always 'conectix')
|
||||||
|
# 40 0x28 Disk size (uint64_t)
|
||||||
|
#
|
||||||
|
# https://github.com/qemu/qemu/blob/master/block/vpc.c
|
||||||
|
class VHDInspector(FileInspector):
|
||||||
|
"""Connectix/MS VPC VHD Format
|
||||||
|
|
||||||
|
This should only require about 512 bytes of the beginning of the file
|
||||||
|
to determine the virtual size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super(VHDInspector, self).__init__(*a, **k)
|
||||||
|
self.new_region('header', CaptureRegion(0, 512))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
return self.region('header').data.startswith(b'conectix')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not self.format_match:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return struct.unpack('>Q', self.region('header').data[40:48])[0]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'vhd'
|
||||||
|
|
||||||
|
|
||||||
|
# The VHDX format consists of a complex dynamic little-endian
|
||||||
|
# structure with multiple regions of metadata and data, linked by
|
||||||
|
# offsets with in the file (and within regions), identified by MSFT
|
||||||
|
# GUID strings. The header is a 320KiB structure, only a few pieces of
|
||||||
|
# which we actually need to capture and interpret:
|
||||||
|
#
|
||||||
|
# Dec Hex Name
|
||||||
|
# 0 0x00000 Identity (Technically 9-bytes, padded to 64KiB, the first
|
||||||
|
# 8 bytes of which are 'vhdxfile')
|
||||||
|
# 196608 0x30000 The Region table (64KiB of a 32-byte header, followed
|
||||||
|
# by up to 2047 36-byte region table entry structures)
|
||||||
|
#
|
||||||
|
# The region table header includes two items we need to read and parse,
|
||||||
|
# which are:
|
||||||
|
#
|
||||||
|
# 196608 0x30000 4-byte signature ('regi')
|
||||||
|
# 196616 0x30008 Entry count (uint32-t)
|
||||||
|
#
|
||||||
|
# The region table entries follow the region table header immediately
|
||||||
|
# and are identified by a 16-byte GUID, and provide an offset of the
|
||||||
|
# start of that region. We care about the "metadata region", identified
|
||||||
|
# by the METAREGION class variable. The region table entry is (offsets
|
||||||
|
# from the beginning of the entry, since it could be in multiple places):
|
||||||
|
#
|
||||||
|
# 0 0x00000 16-byte MSFT GUID
|
||||||
|
# 16 0x00010 Offset of the actual metadata region (uint64_t)
|
||||||
|
#
|
||||||
|
# When we find the METAREGION table entry, we need to grab that offset
|
||||||
|
# and start examining the region structure at that point. That
|
||||||
|
# consists of a metadata table of structures, which point to places in
|
||||||
|
# the data in an unstructured space that follows. The header is
|
||||||
|
# (offsets relative to the region start):
|
||||||
|
#
|
||||||
|
# 0 0x00000 8-byte signature ('metadata')
|
||||||
|
# . . .
|
||||||
|
# 16 0x00010 2-byte entry count (up to 2047 entries max)
|
||||||
|
#
|
||||||
|
# This header is followed by the specified number of metadata entry
|
||||||
|
# structures, identified by GUID:
|
||||||
|
#
|
||||||
|
# 0 0x00000 16-byte MSFT GUID
|
||||||
|
# 16 0x00010 4-byte offset (uint32_t, relative to the beginning of
|
||||||
|
# the metadata region)
|
||||||
|
#
|
||||||
|
# We need to find the "Virtual Disk Size" metadata item, identified by
|
||||||
|
# the GUID in the VIRTUAL_DISK_SIZE class variable, grab the offset,
|
||||||
|
# add it to the offset of the metadata region, and examine that 8-byte
|
||||||
|
# chunk of data that follows.
|
||||||
|
#
|
||||||
|
# The "Virtual Disk Size" is a naked uint64_t which contains the size
|
||||||
|
# of the virtual disk, and is our ultimate target here.
|
||||||
|
#
|
||||||
|
# https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-vhdx/83e061f8-f6e2-4de1-91bd-5d518a43d477
|
||||||
|
class VHDXInspector(FileInspector):
|
||||||
|
"""MS VHDX Format
|
||||||
|
|
||||||
|
This requires some complex parsing of the stream. The first 256KiB
|
||||||
|
of the image is stored to get the header and region information,
|
||||||
|
and then we capture the first metadata region to read those
|
||||||
|
records, find the location of the virtual size data and parse
|
||||||
|
it. This needs to store the metadata table entries up until the
|
||||||
|
VDS record, which may consist of up to 2047 32-byte entries at
|
||||||
|
max. Finally, it must store a chunk of data at the offset of the
|
||||||
|
actual VDS uint64.
|
||||||
|
|
||||||
|
"""
|
||||||
|
METAREGION = '8B7CA206-4790-4B9A-B8FE-575F050F886E'
|
||||||
|
VIRTUAL_DISK_SIZE = '2FA54224-CD1B-4876-B211-5DBED83BF4B8'
|
||||||
|
VHDX_METADATA_TABLE_MAX_SIZE = 32 * 2048 # From qemu
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super(VHDXInspector, self).__init__(*a, **k)
|
||||||
|
self.new_region('ident', CaptureRegion(0, 32))
|
||||||
|
self.new_region('header', CaptureRegion(192 * 1024, 64 * 1024))
|
||||||
|
|
||||||
|
def post_process(self):
|
||||||
|
# After reading a chunk, we may have the following conditions:
|
||||||
|
#
|
||||||
|
# 1. We may have just completed the header region, and if so,
|
||||||
|
# we need to immediately read and calculate the location of
|
||||||
|
# the metadata region, as it may be starting in the same
|
||||||
|
# read we just did.
|
||||||
|
# 2. We may have just completed the metadata region, and if so,
|
||||||
|
# we need to immediately calculate the location of the
|
||||||
|
# "virtual disk size" record, as it may be starting in the
|
||||||
|
# same read we just did.
|
||||||
|
if self.region('header').complete and not self.has_region('metadata'):
|
||||||
|
region = self._find_meta_region()
|
||||||
|
if region:
|
||||||
|
self.new_region('metadata', region)
|
||||||
|
elif self.has_region('metadata') and not self.has_region('vds'):
|
||||||
|
region = self._find_meta_entry(self.VIRTUAL_DISK_SIZE)
|
||||||
|
if region:
|
||||||
|
self.new_region('vds', region)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
return self.region('ident').data.startswith(b'vhdxfile')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _guid(buf):
|
||||||
|
"""Format a MSFT GUID from the 16-byte input buffer."""
|
||||||
|
guid_format = '<IHHBBBBBBBB'
|
||||||
|
return '%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X' % (
|
||||||
|
struct.unpack(guid_format, buf))
|
||||||
|
|
||||||
|
def _find_meta_region(self):
|
||||||
|
# The region table entries start after a 16-byte table header
|
||||||
|
region_entry_first = 16
|
||||||
|
|
||||||
|
# Parse the region table header to find the number of regions
|
||||||
|
regi, cksum, count, reserved = struct.unpack(
|
||||||
|
'<IIII', self.region('header').data[:16])
|
||||||
|
if regi != 0x69676572:
|
||||||
|
raise ImageFormatError('Region signature not found at %x' % (
|
||||||
|
self.region('header').offset))
|
||||||
|
|
||||||
|
if count >= 2048:
|
||||||
|
raise ImageFormatError('Region count is %i (limit 2047)' % count)
|
||||||
|
|
||||||
|
# Process the regions until we find the metadata one; grab the
|
||||||
|
# offset and return
|
||||||
|
self._log.debug('Region entry first is %x', region_entry_first)
|
||||||
|
self._log.debug('Region entries %i', count)
|
||||||
|
meta_offset = 0
|
||||||
|
for i in range(0, count):
|
||||||
|
entry_start = region_entry_first + (i * 32)
|
||||||
|
entry_end = entry_start + 32
|
||||||
|
entry = self.region('header').data[entry_start:entry_end]
|
||||||
|
self._log.debug('Entry offset is %x', entry_start)
|
||||||
|
|
||||||
|
# GUID is the first 16 bytes
|
||||||
|
guid = self._guid(entry[:16])
|
||||||
|
if guid == self.METAREGION:
|
||||||
|
# This entry is the metadata region entry
|
||||||
|
meta_offset, meta_len, meta_req = struct.unpack(
|
||||||
|
'<QII', entry[16:])
|
||||||
|
self._log.debug('Meta entry %i specifies offset: %x',
|
||||||
|
i, meta_offset)
|
||||||
|
# NOTE(danms): The meta_len in the region descriptor is the
|
||||||
|
# entire size of the metadata table and data. This can be
|
||||||
|
# very large, so we should only capture the size required
|
||||||
|
# for the maximum length of the table, which is one 32-byte
|
||||||
|
# table header, plus up to 2047 32-byte entries.
|
||||||
|
meta_len = 2048 * 32
|
||||||
|
return CaptureRegion(meta_offset, meta_len)
|
||||||
|
|
||||||
|
self._log.warning('Did not find metadata region')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_meta_entry(self, desired_guid):
|
||||||
|
meta_buffer = self.region('metadata').data
|
||||||
|
if len(meta_buffer) < 32:
|
||||||
|
# Not enough data yet for full header
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Make sure we found the metadata region by checking the signature
|
||||||
|
sig, reserved, count = struct.unpack('<8sHH', meta_buffer[:12])
|
||||||
|
if sig != b'metadata':
|
||||||
|
raise ImageFormatError(
|
||||||
|
'Invalid signature for metadata region: %r' % sig)
|
||||||
|
|
||||||
|
entries_size = 32 + (count * 32)
|
||||||
|
if len(meta_buffer) < entries_size:
|
||||||
|
# Not enough data yet for all metadata entries. This is not
|
||||||
|
# strictly necessary as we could process whatever we have until
|
||||||
|
# we find the V-D-S one, but there are only 2047 32-byte
|
||||||
|
# entries max (~64k).
|
||||||
|
return None
|
||||||
|
|
||||||
|
if count >= 2048:
|
||||||
|
raise ImageFormatError(
|
||||||
|
'Metadata item count is %i (limit 2047)' % count)
|
||||||
|
|
||||||
|
for i in range(0, count):
|
||||||
|
entry_offset = 32 + (i * 32)
|
||||||
|
guid = self._guid(meta_buffer[entry_offset:entry_offset + 16])
|
||||||
|
if guid == desired_guid:
|
||||||
|
# Found the item we are looking for by id.
|
||||||
|
# Stop our region from capturing
|
||||||
|
item_offset, item_length, _reserved = struct.unpack(
|
||||||
|
'<III',
|
||||||
|
meta_buffer[entry_offset + 16:entry_offset + 28])
|
||||||
|
item_length = min(item_length,
|
||||||
|
self.VHDX_METADATA_TABLE_MAX_SIZE)
|
||||||
|
self.region('metadata').length = len(meta_buffer)
|
||||||
|
self._log.debug('Found entry at offset %x', item_offset)
|
||||||
|
# Metadata item offset is from the beginning of the metadata
|
||||||
|
# region, not the file.
|
||||||
|
return CaptureRegion(
|
||||||
|
self.region('metadata').offset + item_offset,
|
||||||
|
item_length)
|
||||||
|
|
||||||
|
self._log.warning('Did not find guid %s', desired_guid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
# Until we have found the offset and have enough metadata buffered
|
||||||
|
# to read it, return "unknown"
|
||||||
|
if not self.has_region('vds') or not self.region('vds').complete:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
size, = struct.unpack('<Q', self.region('vds').data)
|
||||||
|
return size
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'vhdx'
|
||||||
|
|
||||||
|
|
||||||
|
# The VMDK format comes in a large number of variations, but the
|
||||||
|
# single-file 'monolithicSparse' version 4 one is mostly what we care
|
||||||
|
# about. It contains a 512-byte little-endian header, followed by a
|
||||||
|
# variable-length "descriptor" region of text. The header looks like:
|
||||||
|
#
|
||||||
|
# Dec Hex Name
|
||||||
|
# 0 0x00 4-byte magic string 'KDMV'
|
||||||
|
# 4 0x04 Version (uint32_t)
|
||||||
|
# 8 0x08 Flags (uint32_t, unused by us)
|
||||||
|
# 16 0x10 Number of 512 byte sectors in the disk (uint64_t)
|
||||||
|
# 24 0x18 Granularity (uint64_t, unused by us)
|
||||||
|
# 32 0x20 Descriptor offset in 512-byte sectors (uint64_t)
|
||||||
|
# 40 0x28 Descriptor size in 512-byte sectors (uint64_t)
|
||||||
|
#
|
||||||
|
# After we have the header, we need to find the descriptor region,
|
||||||
|
# which starts at the sector identified in the "descriptor offset"
|
||||||
|
# field, and is "descriptor size" 512-byte sectors long. Once we have
|
||||||
|
# that region, we need to parse it as text, looking for the
|
||||||
|
# createType=XXX line that specifies the mechanism by which the data
|
||||||
|
# extents are stored in this file. We only support the
|
||||||
|
# "monolithicSparse" format, so we just need to confirm that this file
|
||||||
|
# contains that specifier.
|
||||||
|
#
|
||||||
|
# https://www.vmware.com/app/vmdk/?src=vmdk
|
||||||
|
class VMDKInspector(FileInspector):
|
||||||
|
"""vmware VMDK format (monolithicSparse and streamOptimized variants only)
|
||||||
|
|
||||||
|
This needs to store the 512 byte header and the descriptor region
|
||||||
|
which should be just after that. The descriptor region is some
|
||||||
|
variable number of 512 byte sectors, but is just text defining the
|
||||||
|
layout of the disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The beginning and max size of the descriptor is also hardcoded in Qemu
|
||||||
|
# at 0x200 and 1MB - 1
|
||||||
|
DESC_OFFSET = 0x200
|
||||||
|
DESC_MAX_SIZE = (1 << 20) - 1
|
||||||
|
GD_AT_END = 0xffffffffffffffff
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super(VMDKInspector, self).__init__(*a, **k)
|
||||||
|
self.new_region('header', CaptureRegion(0, 512))
|
||||||
|
|
||||||
|
def post_process(self):
|
||||||
|
# If we have just completed the header region, we need to calculate
|
||||||
|
# the location and length of the descriptor, which should immediately
|
||||||
|
# follow and may have been partially-read in this read.
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
(sig, ver, _flags, _sectors, _grain, desc_sec, desc_num,
|
||||||
|
_numGTEsperGT, _rgdOffset, gdOffset) = struct.unpack(
|
||||||
|
'<4sIIQQQQIQQ', self.region('header').data[:64])
|
||||||
|
|
||||||
|
if sig != b'KDMV':
|
||||||
|
raise ImageFormatError('Signature KDMV not found: %r' % sig)
|
||||||
|
|
||||||
|
if ver not in (1, 2, 3):
|
||||||
|
raise ImageFormatError('Unsupported format version %i' % ver)
|
||||||
|
|
||||||
|
if gdOffset == self.GD_AT_END:
|
||||||
|
# This means we have a footer, which takes precedence over the
|
||||||
|
# header, which we cannot support since we stream.
|
||||||
|
raise ImageFormatError('Unsupported VMDK footer')
|
||||||
|
|
||||||
|
# Since we parse both desc_sec and desc_num (the location of the
|
||||||
|
# VMDK's descriptor, expressed in 512 bytes sectors) we enforce a
|
||||||
|
# check on the bounds to create a reasonable CaptureRegion. This
|
||||||
|
# is similar to how it's done in qemu.
|
||||||
|
desc_offset = desc_sec * 512
|
||||||
|
desc_size = min(desc_num * 512, self.DESC_MAX_SIZE)
|
||||||
|
if desc_offset != self.DESC_OFFSET:
|
||||||
|
raise ImageFormatError("Wrong descriptor location")
|
||||||
|
|
||||||
|
if not self.has_region('descriptor'):
|
||||||
|
self.new_region('descriptor', CaptureRegion(
|
||||||
|
desc_offset, desc_size))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
return self.region('header').data.startswith(b'KDMV')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
if not self.has_region('descriptor'):
|
||||||
|
# Not enough data yet
|
||||||
|
return 0
|
||||||
|
|
||||||
|
descriptor_rgn = self.region('descriptor')
|
||||||
|
if not descriptor_rgn.complete:
|
||||||
|
# Not enough data yet
|
||||||
|
return 0
|
||||||
|
|
||||||
|
descriptor = descriptor_rgn.data
|
||||||
|
type_idx = descriptor.index(b'createType="') + len(b'createType="')
|
||||||
|
type_end = descriptor.find(b'"', type_idx)
|
||||||
|
# Make sure we don't grab and log a huge chunk of data in a
|
||||||
|
# maliciously-formatted descriptor region
|
||||||
|
if type_end - type_idx < 64:
|
||||||
|
vmdktype = descriptor[type_idx:type_end]
|
||||||
|
else:
|
||||||
|
vmdktype = b'formatnotfound'
|
||||||
|
if vmdktype not in (b'monolithicSparse', b'streamOptimized'):
|
||||||
|
LOG.warning('Unsupported VMDK format %s', vmdktype)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# If we have the descriptor, we definitely have the header
|
||||||
|
_sig, _ver, _flags, sectors, _grain, _desc_sec, _desc_num = (
|
||||||
|
struct.unpack('<IIIQQQQ', self.region('header').data[:44]))
|
||||||
|
|
||||||
|
return sectors * 512
|
||||||
|
|
||||||
|
def safety_check(self):
|
||||||
|
if (not self.has_region('descriptor') or
|
||||||
|
not self.region('descriptor').complete):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Descriptor is padded to 512 bytes
|
||||||
|
desc_data = self.region('descriptor').data.rstrip(b'\x00')
|
||||||
|
# Descriptor is actually case-insensitive ASCII text
|
||||||
|
desc_text = desc_data.decode('ascii').lower()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
LOG.error('VMDK descriptor failed to decode as ASCII')
|
||||||
|
raise ImageFormatError('Invalid VMDK descriptor data')
|
||||||
|
|
||||||
|
extent_access = ('rw', 'rdonly', 'noaccess')
|
||||||
|
header_fields = []
|
||||||
|
extents = []
|
||||||
|
ddb = []
|
||||||
|
|
||||||
|
# NOTE(danms): Cautiously parse the VMDK descriptor. Each line must
|
||||||
|
# be something we understand, otherwise we refuse it.
|
||||||
|
for line in [x.strip() for x in desc_text.split('\n')]:
|
||||||
|
if line.startswith('#') or not line:
|
||||||
|
# Blank or comment lines are ignored
|
||||||
|
continue
|
||||||
|
elif line.startswith('ddb'):
|
||||||
|
# DDB lines are allowed (but not used by us)
|
||||||
|
ddb.append(line)
|
||||||
|
elif '=' in line and ' ' not in line.split('=')[0]:
|
||||||
|
# Header fields are a single word followed by an '=' and some
|
||||||
|
# value
|
||||||
|
header_fields.append(line)
|
||||||
|
elif line.split(' ')[0] in extent_access:
|
||||||
|
# Extent lines start with one of the three access modes
|
||||||
|
extents.append(line)
|
||||||
|
else:
|
||||||
|
# Anything else results in a rejection
|
||||||
|
LOG.error('Unsupported line %r in VMDK descriptor', line)
|
||||||
|
raise ImageFormatError('Invalid VMDK descriptor data')
|
||||||
|
|
||||||
|
# Check all the extent lines for concerning content
|
||||||
|
for extent_line in extents:
|
||||||
|
if '/' in extent_line:
|
||||||
|
LOG.error('Extent line %r contains unsafe characters',
|
||||||
|
extent_line)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not extents:
|
||||||
|
LOG.error('VMDK file specified no extents')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'vmdk'
|
||||||
|
|
||||||
|
|
||||||
|
# The VirtualBox VDI format consists of a 512-byte little-endian
|
||||||
|
# header, some of which we care about:
|
||||||
|
#
|
||||||
|
# Dec Hex Name
|
||||||
|
# 64 0x40 4-byte Magic (0xbeda107f)
|
||||||
|
# . . .
|
||||||
|
# 368 0x170 Size in bytes (uint64_t)
|
||||||
|
#
|
||||||
|
# https://github.com/qemu/qemu/blob/master/block/vdi.c
|
||||||
|
class VDIInspector(FileInspector):
|
||||||
|
"""VirtualBox VDI format
|
||||||
|
|
||||||
|
This only needs to store the first 512 bytes of the image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super(VDIInspector, self).__init__(*a, **k)
|
||||||
|
self.new_region('header', CaptureRegion(0, 512))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_match(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return False
|
||||||
|
|
||||||
|
signature, = struct.unpack('<I', self.region('header').data[0x40:0x44])
|
||||||
|
return signature == 0xbeda107f
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_size(self):
|
||||||
|
if not self.region('header').complete:
|
||||||
|
return 0
|
||||||
|
if not self.format_match:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
size, = struct.unpack('<Q', self.region('header').data[0x170:0x178])
|
||||||
|
return size
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'vdi'
|
||||||
|
|
||||||
|
|
||||||
|
class InfoWrapper(object):
|
||||||
|
"""A file-like object that wraps another and updates a format inspector.
|
||||||
|
|
||||||
|
This passes chunks to the format inspector while reading. If the inspector
|
||||||
|
fails, it logs the error and stops calling it, but continues proxying data
|
||||||
|
from the source to its user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source, fmt):
|
||||||
|
self._source = source
|
||||||
|
self._format = fmt
|
||||||
|
self._error = False
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _process_chunk(self, chunk):
|
||||||
|
if not self._error:
|
||||||
|
try:
|
||||||
|
self._format.eat_chunk(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
# Absolutely do not allow the format inspector to break
|
||||||
|
# our streaming of the image. If we failed, just stop
|
||||||
|
# trying, log and keep going.
|
||||||
|
LOG.error('Format inspector failed, aborting: %s', e)
|
||||||
|
self._error = True
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
try:
|
||||||
|
chunk = next(self._source)
|
||||||
|
except StopIteration:
|
||||||
|
raise
|
||||||
|
self._process_chunk(chunk)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
chunk = self._source.read(size)
|
||||||
|
self._process_chunk(chunk)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if hasattr(self._source, 'close'):
|
||||||
|
self._source.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_inspector(format_name):
|
||||||
|
"""Returns a FormatInspector class based on the given name.
|
||||||
|
|
||||||
|
:param format_name: The name of the disk_format (raw, qcow2, etc).
|
||||||
|
:returns: A FormatInspector or None if unsupported.
|
||||||
|
"""
|
||||||
|
formats = {
|
||||||
|
'raw': FileInspector,
|
||||||
|
'qcow2': QcowInspector,
|
||||||
|
'vhd': VHDInspector,
|
||||||
|
'vhdx': VHDXInspector,
|
||||||
|
'vmdk': VMDKInspector,
|
||||||
|
'vdi': VDIInspector,
|
||||||
|
}
|
||||||
|
|
||||||
|
return formats.get(format_name)
|
||||||
@@ -29,6 +29,7 @@ from oslo_utils.fixture import uuidsentinel as uuids
|
|||||||
from nova.compute import utils as compute_utils
|
from nova.compute import utils as compute_utils
|
||||||
from nova import context
|
from nova import context
|
||||||
from nova import exception
|
from nova import exception
|
||||||
|
from nova.image import format_inspector
|
||||||
from nova import objects
|
from nova import objects
|
||||||
from nova.objects import fields as obj_fields
|
from nova.objects import fields as obj_fields
|
||||||
import nova.privsep.fs
|
import nova.privsep.fs
|
||||||
@@ -321,11 +322,13 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
mock_images.assert_called_once_with(
|
mock_images.assert_called_once_with(
|
||||||
_context, image_id, target, trusted_certs)
|
_context, image_id, target, trusted_certs)
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(format_inspector, 'get_inspector')
|
||||||
@mock.patch.object(compute_utils, 'disk_ops_semaphore')
|
@mock.patch.object(compute_utils, 'disk_ops_semaphore')
|
||||||
@mock.patch('nova.privsep.utils.supports_direct_io', return_value=True)
|
@mock.patch('nova.privsep.utils.supports_direct_io', return_value=True)
|
||||||
@mock.patch('nova.privsep.qemu.unprivileged_convert_image')
|
@mock.patch('nova.privsep.qemu.unprivileged_convert_image')
|
||||||
def test_fetch_raw_image(self, mock_convert_image, mock_direct_io,
|
def test_fetch_raw_image(self, mock_convert_image, mock_direct_io,
|
||||||
mock_disk_op_sema):
|
mock_disk_op_sema, mock_gi, mock_glance):
|
||||||
|
|
||||||
def fake_rename(old, new):
|
def fake_rename(old, new):
|
||||||
self.executes.append(('mv', old, new))
|
self.executes.append(('mv', old, new))
|
||||||
@@ -336,7 +339,7 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
def fake_rm_on_error(path, remove=None):
|
def fake_rm_on_error(path, remove=None):
|
||||||
self.executes.append(('rm', '-f', path))
|
self.executes.append(('rm', '-f', path))
|
||||||
|
|
||||||
def fake_qemu_img_info(path):
|
def fake_qemu_img_info(path, format=None):
|
||||||
class FakeImgInfo(object):
|
class FakeImgInfo(object):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -365,6 +368,8 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
self.stub_out('oslo_utils.fileutils.delete_if_exists',
|
self.stub_out('oslo_utils.fileutils.delete_if_exists',
|
||||||
fake_rm_on_error)
|
fake_rm_on_error)
|
||||||
|
|
||||||
|
mock_inspector = mock_gi.return_value.from_file.return_value
|
||||||
|
|
||||||
# Since the remove param of fileutils.remove_path_on_error()
|
# Since the remove param of fileutils.remove_path_on_error()
|
||||||
# is initialized at load time, we must provide a wrapper
|
# is initialized at load time, we must provide a wrapper
|
||||||
# that explicitly resets it to our fake delete_if_exists()
|
# that explicitly resets it to our fake delete_if_exists()
|
||||||
@@ -375,6 +380,9 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
context = 'opaque context'
|
context = 'opaque context'
|
||||||
image_id = '4'
|
image_id = '4'
|
||||||
|
|
||||||
|
# Make sure qcow2 gets converted to raw
|
||||||
|
mock_inspector.safety_check.return_value = True
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
target = 't.qcow2'
|
target = 't.qcow2'
|
||||||
self.executes = []
|
self.executes = []
|
||||||
expected_commands = [('rm', 't.qcow2.part'),
|
expected_commands = [('rm', 't.qcow2.part'),
|
||||||
@@ -386,14 +394,28 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
't.qcow2.part', 't.qcow2.converted', 'qcow2', 'raw',
|
't.qcow2.part', 't.qcow2.converted', 'qcow2', 'raw',
|
||||||
CONF.instances_path, False)
|
CONF.instances_path, False)
|
||||||
mock_convert_image.reset_mock()
|
mock_convert_image.reset_mock()
|
||||||
|
mock_inspector.safety_check.assert_called_once_with()
|
||||||
|
mock_gi.assert_called_once_with('qcow2')
|
||||||
|
|
||||||
|
# Make sure raw does not get converted
|
||||||
|
mock_gi.reset_mock()
|
||||||
|
mock_inspector.safety_check.reset_mock()
|
||||||
|
mock_inspector.safety_check.return_value = True
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'raw'}
|
||||||
target = 't.raw'
|
target = 't.raw'
|
||||||
self.executes = []
|
self.executes = []
|
||||||
expected_commands = [('mv', 't.raw.part', 't.raw')]
|
expected_commands = [('mv', 't.raw.part', 't.raw')]
|
||||||
images.fetch_to_raw(context, image_id, target)
|
images.fetch_to_raw(context, image_id, target)
|
||||||
self.assertEqual(self.executes, expected_commands)
|
self.assertEqual(self.executes, expected_commands)
|
||||||
mock_convert_image.assert_not_called()
|
mock_convert_image.assert_not_called()
|
||||||
|
mock_inspector.safety_check.assert_called_once_with()
|
||||||
|
mock_gi.assert_called_once_with('raw')
|
||||||
|
|
||||||
|
# Make sure safety check failure prevents us from proceeding
|
||||||
|
mock_gi.reset_mock()
|
||||||
|
mock_inspector.safety_check.reset_mock()
|
||||||
|
mock_inspector.safety_check.return_value = False
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
target = 'backing.qcow2'
|
target = 'backing.qcow2'
|
||||||
self.executes = []
|
self.executes = []
|
||||||
expected_commands = [('rm', '-f', 'backing.qcow2.part')]
|
expected_commands = [('rm', '-f', 'backing.qcow2.part')]
|
||||||
@@ -401,6 +423,24 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
|||||||
images.fetch_to_raw, context, image_id, target)
|
images.fetch_to_raw, context, image_id, target)
|
||||||
self.assertEqual(self.executes, expected_commands)
|
self.assertEqual(self.executes, expected_commands)
|
||||||
mock_convert_image.assert_not_called()
|
mock_convert_image.assert_not_called()
|
||||||
|
mock_inspector.safety_check.assert_called_once_with()
|
||||||
|
mock_gi.assert_called_once_with('qcow2')
|
||||||
|
|
||||||
|
# Make sure a format mismatch prevents us from proceeding
|
||||||
|
mock_gi.reset_mock()
|
||||||
|
mock_inspector.safety_check.reset_mock()
|
||||||
|
mock_inspector.safety_check.side_effect = (
|
||||||
|
format_inspector.ImageFormatError)
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
|
target = 'backing.qcow2'
|
||||||
|
self.executes = []
|
||||||
|
expected_commands = [('rm', '-f', 'backing.qcow2.part')]
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, context, image_id, target)
|
||||||
|
self.assertEqual(self.executes, expected_commands)
|
||||||
|
mock_convert_image.assert_not_called()
|
||||||
|
mock_inspector.safety_check.assert_called_once_with()
|
||||||
|
mock_gi.assert_called_once_with('qcow2')
|
||||||
|
|
||||||
del self.executes
|
del self.executes
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from oslo_utils import imageutils
|
|||||||
|
|
||||||
from nova.compute import utils as compute_utils
|
from nova.compute import utils as compute_utils
|
||||||
from nova import exception
|
from nova import exception
|
||||||
|
from nova.image import format_inspector
|
||||||
from nova import test
|
from nova import test
|
||||||
from nova.virt import images
|
from nova.virt import images
|
||||||
|
|
||||||
@@ -99,11 +100,17 @@ class QemuTestCase(test.NoDBTestCase):
|
|||||||
exists.assert_called_once_with(path)
|
exists.assert_called_once_with(path)
|
||||||
mocked_execute.assert_called_once()
|
mocked_execute.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
@mock.patch.object(images, 'convert_image',
|
@mock.patch.object(images, 'convert_image',
|
||||||
side_effect=exception.ImageUnacceptable)
|
side_effect=exception.ImageUnacceptable)
|
||||||
@mock.patch.object(images, 'qemu_img_info')
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
@mock.patch.object(images, 'fetch')
|
@mock.patch.object(images, 'fetch')
|
||||||
def test_fetch_to_raw_errors(self, convert_image, qemu_img_info, fetch):
|
def test_fetch_to_raw_errors(self, convert_image, qemu_img_info, fetch,
|
||||||
|
get_inspector, glance):
|
||||||
|
inspector = get_inspector.return_value.from_file.return_value
|
||||||
|
inspector.safety_check.return_value = True
|
||||||
|
glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
qemu_img_info.backing_file = None
|
qemu_img_info.backing_file = None
|
||||||
qemu_img_info.file_format = 'qcow2'
|
qemu_img_info.file_format = 'qcow2'
|
||||||
qemu_img_info.virtual_size = 20
|
qemu_img_info.virtual_size = 20
|
||||||
@@ -112,12 +119,17 @@ class QemuTestCase(test.NoDBTestCase):
|
|||||||
images.fetch_to_raw,
|
images.fetch_to_raw,
|
||||||
None, 'href123', '/no/path')
|
None, 'href123', '/no/path')
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
@mock.patch.object(images, 'convert_image',
|
@mock.patch.object(images, 'convert_image',
|
||||||
side_effect=exception.ImageUnacceptable)
|
side_effect=exception.ImageUnacceptable)
|
||||||
@mock.patch.object(images, 'qemu_img_info')
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
@mock.patch.object(images, 'fetch')
|
@mock.patch.object(images, 'fetch')
|
||||||
def test_fetch_to_raw_data_file(self, convert_image, qemu_img_info_fn,
|
def test_fetch_to_raw_data_file(self, convert_image, qemu_img_info_fn,
|
||||||
fetch):
|
fetch, mock_gi, mock_glance):
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
|
inspector = mock_gi.return_value.from_file.return_value
|
||||||
|
inspector.safety_check.return_value = True
|
||||||
# NOTE(danms): the above test needs the following line as well, as it
|
# NOTE(danms): the above test needs the following line as well, as it
|
||||||
# is broken without it.
|
# is broken without it.
|
||||||
qemu_img_info = qemu_img_info_fn.return_value
|
qemu_img_info = qemu_img_info_fn.return_value
|
||||||
@@ -130,12 +142,16 @@ class QemuTestCase(test.NoDBTestCase):
|
|||||||
images.fetch_to_raw,
|
images.fetch_to_raw,
|
||||||
None, 'href123', '/no/path')
|
None, 'href123', '/no/path')
|
||||||
|
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
@mock.patch('os.rename')
|
@mock.patch('os.rename')
|
||||||
@mock.patch.object(images, 'qemu_img_info')
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
@mock.patch.object(images, 'fetch')
|
@mock.patch.object(images, 'fetch')
|
||||||
def test_fetch_to_raw_from_raw(self, fetch, qemu_img_info_fn, mock_rename):
|
def test_fetch_to_raw_from_raw(self, fetch, qemu_img_info_fn, mock_rename,
|
||||||
|
mock_glance, mock_gi):
|
||||||
# Make sure we support a case where we fetch an already-raw image and
|
# Make sure we support a case where we fetch an already-raw image and
|
||||||
# qemu-img returns None for "format_specific".
|
# qemu-img returns None for "format_specific".
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'raw'}
|
||||||
qemu_img_info = qemu_img_info_fn.return_value
|
qemu_img_info = qemu_img_info_fn.return_value
|
||||||
qemu_img_info.file_format = 'raw'
|
qemu_img_info.file_format = 'raw'
|
||||||
qemu_img_info.backing_file = None
|
qemu_img_info.backing_file = None
|
||||||
@@ -198,9 +214,15 @@ class QemuTestCase(test.NoDBTestCase):
|
|||||||
imageutils.QemuImgInfo(jsonutils.dumps(info),
|
imageutils.QemuImgInfo(jsonutils.dumps(info),
|
||||||
format='json'))
|
format='json'))
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
@mock.patch.object(images, 'fetch')
|
@mock.patch.object(images, 'fetch')
|
||||||
@mock.patch('nova.privsep.qemu.unprivileged_qemu_img_info')
|
@mock.patch('nova.privsep.qemu.unprivileged_qemu_img_info')
|
||||||
def test_fetch_checks_vmdk_rules(self, mock_info, mock_fetch):
|
def test_fetch_checks_vmdk_rules(self, mock_info, mock_fetch, mock_gi,
|
||||||
|
mock_glance):
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'vmdk'}
|
||||||
|
inspector = mock_gi.return_value.from_file.return_value
|
||||||
|
inspector.safety_check.return_value = True
|
||||||
info = {'format': 'vmdk',
|
info = {'format': 'vmdk',
|
||||||
'format-specific': {
|
'format-specific': {
|
||||||
'type': 'vmdk',
|
'type': 'vmdk',
|
||||||
@@ -212,3 +234,109 @@ class QemuTestCase(test.NoDBTestCase):
|
|||||||
e = self.assertRaises(exception.ImageUnacceptable,
|
e = self.assertRaises(exception.ImageUnacceptable,
|
||||||
images.fetch_to_raw, None, 'foo', 'anypath')
|
images.fetch_to_raw, None, 'foo', 'anypath')
|
||||||
self.assertIn('Invalid VMDK create-type specified', str(e))
|
self.assertIn('Invalid VMDK create-type specified', str(e))
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
@mock.patch.object(images, 'fetch')
|
||||||
|
def test_fetch_to_raw_inspector(self, fetch, qemu_img_info, mock_gi,
|
||||||
|
mock_glance):
|
||||||
|
# Image claims to be qcow2, is qcow2, but fails safety check, so we
|
||||||
|
# abort before qemu-img-info
|
||||||
|
mock_glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
|
inspector = mock_gi.return_value.from_file.return_value
|
||||||
|
inspector.safety_check.return_value = False
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
qemu_img_info.assert_not_called()
|
||||||
|
mock_gi.assert_called_once_with('qcow2')
|
||||||
|
mock_gi.return_value.from_file.assert_called_once_with('/no.path.part')
|
||||||
|
inspector.safety_check.assert_called_once_with()
|
||||||
|
mock_glance.get.assert_called_once_with(None, 'href123')
|
||||||
|
|
||||||
|
# Image claims to be qcow2, is qcow2, passes safety check, so we make
|
||||||
|
# it all the way to qemu-img-info
|
||||||
|
inspector.safety_check.return_value = True
|
||||||
|
qemu_img_info.side_effect = test.TestingException
|
||||||
|
self.assertRaises(test.TestingException,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
|
||||||
|
# Image claims to be qcow2 in glance, but the image is something else,
|
||||||
|
# so we abort before qemu-img-info
|
||||||
|
qemu_img_info.reset_mock()
|
||||||
|
mock_gi.reset_mock()
|
||||||
|
inspector.safety_check.reset_mock()
|
||||||
|
mock_gi.return_value.from_file.side_effect = (
|
||||||
|
format_inspector.ImageFormatError)
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
mock_gi.assert_called_once_with('qcow2')
|
||||||
|
inspector.safety_check.assert_not_called()
|
||||||
|
qemu_img_info.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
@mock.patch.object(images, 'fetch')
|
||||||
|
def test_fetch_to_raw_inspector_disabled(self, fetch, qemu_img_info,
|
||||||
|
mock_gi, mock_glance):
|
||||||
|
self.flags(disable_deep_image_inspection=True,
|
||||||
|
group='workarounds')
|
||||||
|
qemu_img_info.side_effect = test.TestingException
|
||||||
|
self.assertRaises(test.TestingException,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
# If deep inspection is disabled, we should never call the inspector
|
||||||
|
mock_gi.assert_not_called()
|
||||||
|
# ... and we let qemu-img detect the format itself.
|
||||||
|
qemu_img_info.assert_called_once_with('/no.path.part',
|
||||||
|
format=None)
|
||||||
|
mock_glance.get.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
def test_fetch_inspect_ami(self, imginfo, glance):
|
||||||
|
glance.get.return_value = {'disk_format': 'ami'}
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
# Make sure 'ami was translated into 'raw' before we call qemu-img
|
||||||
|
imginfo.assert_called_once_with('/no.path.part', format='raw')
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
def test_fetch_inspect_aki(self, imginfo, glance):
|
||||||
|
glance.get.return_value = {'disk_format': 'aki'}
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
# Make sure 'aki was translated into 'raw' before we call qemu-img
|
||||||
|
imginfo.assert_called_once_with('/no.path.part', format='raw')
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
def test_fetch_inspect_ari(self, imginfo, glance):
|
||||||
|
glance.get.return_value = {'disk_format': 'ari'}
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
# Make sure 'aki was translated into 'raw' before we call qemu-img
|
||||||
|
imginfo.assert_called_once_with('/no.path.part', format='raw')
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
def test_fetch_inspect_unknown_format(self, imginfo, glance):
|
||||||
|
glance.get.return_value = {'disk_format': 'commodore-64-disk'}
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
# Unsupported formats do not make it past deep inspection
|
||||||
|
imginfo.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.object(images, 'IMAGE_API')
|
||||||
|
@mock.patch.object(images, 'qemu_img_info')
|
||||||
|
@mock.patch('nova.image.format_inspector.get_inspector')
|
||||||
|
def test_fetch_inspect_disagrees_qemu(self, mock_gi, imginfo, glance):
|
||||||
|
glance.get.return_value = {'disk_format': 'qcow2'}
|
||||||
|
# Glance and inspector think it is a qcow2 file, but qemu-img does not
|
||||||
|
# agree. It was forced to interpret as a qcow2, but returned no
|
||||||
|
# format information as a result.
|
||||||
|
imginfo.return_value.data_file = None
|
||||||
|
self.assertRaises(exception.ImageUnacceptable,
|
||||||
|
images.fetch_to_raw, None, 'href123', '/no.path')
|
||||||
|
imginfo.assert_called_once_with('/no.path.part', format='qcow2')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from nova.compute import utils as compute_utils
|
|||||||
import nova.conf
|
import nova.conf
|
||||||
from nova import exception
|
from nova import exception
|
||||||
from nova.i18n import _
|
from nova.i18n import _
|
||||||
|
from nova.image import format_inspector
|
||||||
from nova.image import glance
|
from nova.image import glance
|
||||||
import nova.privsep.qemu
|
import nova.privsep.qemu
|
||||||
|
|
||||||
@@ -138,13 +139,57 @@ def check_vmdk_image(image_id, data):
|
|||||||
raise exception.ImageUnacceptable(image_id=image_id, reason=msg)
|
raise exception.ImageUnacceptable(image_id=image_id, reason=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def do_image_deep_inspection(img, image_href, path):
|
||||||
|
disk_format = img['disk_format']
|
||||||
|
try:
|
||||||
|
# NOTE(danms): Use our own cautious inspector module to make sure
|
||||||
|
# the image file passes safety checks.
|
||||||
|
# See https://bugs.launchpad.net/nova/+bug/2059809 for details.
|
||||||
|
inspector_cls = format_inspector.get_inspector(disk_format)
|
||||||
|
if not inspector_cls.from_file(path).safety_check():
|
||||||
|
raise exception.ImageUnacceptable(
|
||||||
|
image_id=image_href,
|
||||||
|
reason=(_('Image does not pass safety check')))
|
||||||
|
except format_inspector.ImageFormatError:
|
||||||
|
# If the inspector we chose based on the image's metadata does not
|
||||||
|
# think the image is the proper format, we refuse to use it.
|
||||||
|
raise exception.ImageUnacceptable(
|
||||||
|
image_id=image_href,
|
||||||
|
reason=_('Image content does not match disk_format'))
|
||||||
|
except AttributeError:
|
||||||
|
# No inspector was found
|
||||||
|
LOG.warning('Unable to perform deep image inspection on type %r',
|
||||||
|
img['disk_format'])
|
||||||
|
if disk_format in ('ami', 'aki', 'ari'):
|
||||||
|
# A lot of things can be in a UEC, although it is typically a raw
|
||||||
|
# filesystem. We really have nothing we can do other than treat it
|
||||||
|
# like a 'raw', which is what qemu-img will detect a filesystem as
|
||||||
|
# anyway. If someone puts a qcow2 inside, we should fail because
|
||||||
|
# we won't do our inspection.
|
||||||
|
disk_format = 'raw'
|
||||||
|
else:
|
||||||
|
raise exception.ImageUnacceptable(
|
||||||
|
image_id=image_href,
|
||||||
|
reason=_('Image not in a supported format'))
|
||||||
|
return disk_format
|
||||||
|
|
||||||
|
|
||||||
def fetch_to_raw(context, image_href, path, trusted_certs=None):
|
def fetch_to_raw(context, image_href, path, trusted_certs=None):
|
||||||
path_tmp = "%s.part" % path
|
path_tmp = "%s.part" % path
|
||||||
fetch(context, image_href, path_tmp, trusted_certs)
|
fetch(context, image_href, path_tmp, trusted_certs)
|
||||||
|
|
||||||
with fileutils.remove_path_on_error(path_tmp):
|
with fileutils.remove_path_on_error(path_tmp):
|
||||||
data = qemu_img_info(path_tmp)
|
if not CONF.workarounds.disable_deep_image_inspection:
|
||||||
|
# If we're doing deep inspection, we take the determined format
|
||||||
|
# from it.
|
||||||
|
img = IMAGE_API.get(context, image_href)
|
||||||
|
force_format = do_image_deep_inspection(img, image_href, path_tmp)
|
||||||
|
else:
|
||||||
|
force_format = None
|
||||||
|
|
||||||
|
# Only run qemu-img after we have done deep inspection (if enabled).
|
||||||
|
# If it was not enabled, we will let it detect the format.
|
||||||
|
data = qemu_img_info(path_tmp, format=force_format)
|
||||||
fmt = data.file_format
|
fmt = data.file_format
|
||||||
if fmt is None:
|
if fmt is None:
|
||||||
raise exception.ImageUnacceptable(
|
raise exception.ImageUnacceptable(
|
||||||
|
|||||||
Reference in New Issue
Block a user