# Copyright 2010 OpenStack Foundation # Copyright 2013 Hewlett-Packard Development Company, L.P. # 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. import abc import datetime from http import client as http_client import os import shutil from urllib import parse as urlparse from oslo_log import log from oslo_utils import strutils from oslo_utils import uuidutils import requests from ironic.common import exception from ironic.common.glance_service.image_service import GlanceImageService from ironic.common.i18n import _ from ironic.common import utils from ironic.conf import CONF IMAGE_CHUNK_SIZE = 1024 * 1024 # 1mb LOG = log.getLogger(__name__) class BaseImageService(object, metaclass=abc.ABCMeta): """Provides retrieval of disk images.""" @abc.abstractmethod def validate_href(self, image_href): """Validate image reference. :param image_href: Image reference. :raises: exception.ImageRefValidationFailed. :returns: Information needed to further operate with an image. """ @abc.abstractmethod def download(self, image_href, image_file): """Downloads image to specified location. :param image_href: Image reference. :param image_file: File object to write data to. :raises: exception.ImageRefValidationFailed. :raises: exception.ImageDownloadFailed. """ @abc.abstractmethod def show(self, image_href): """Get dictionary of image properties. :param image_href: Image reference. :raises: exception.ImageRefValidationFailed. :returns: dictionary of image properties. It has three of them: 'size', 'updated_at' and 'properties'. 'updated_at' attribute is a naive UTC datetime object. """ class HttpImageService(BaseImageService): """Provides retrieval of disk images using HTTP.""" def validate_href(self, image_href, secret=False): """Validate HTTP image reference. :param image_href: Image reference. :param secret: Specify if image_href being validated should not be shown in exception message. :raises: exception.ImageRefValidationFailed if HEAD request failed or returned response code not equal to 200. :raises: exception.ImageRefIsARedirect if the supplied URL is a redirect to a different URL. The caller may be able to handle this. :returns: Response to HEAD request. """ output_url = 'secreturl' if secret else image_href try: verify = strutils.bool_from_string(CONF.webserver_verify_ca, strict=True) except ValueError: verify = CONF.webserver_verify_ca try: # NOTE(TheJulia): Head requests do not work on things that are not # files, but they can be responded with redirects or a 200 OK.... # We don't want to permit endless redirects either, thus not # request an override to the requests default to try and resolve # redirects as otherwise we might end up with something like # HTTPForbidden or a list of files. Both should be okay to at # least know things are okay in a limited fashion. response = requests.head(image_href, verify=verify, timeout=CONF.webserver_connection_timeout) if response.status_code == http_client.MOVED_PERMANENTLY: # NOTE(TheJulia): In the event we receive a redirect, we need # to notify the caller. Before this we would just fail, # but a url which is missing a trailing slash results in a # redirect to a target path, and the caller *may* actually # care about that. redirect = requests.Session().get_redirect_target(response) # Extra guard because this is pointless if there is no # location in the field. Requests also properly formats # our string for us, or gives us None. if redirect: raise exception.ImageRefIsARedirect( image_ref=image_href, redirect_url=redirect) if (response.status_code == http_client.FORBIDDEN and str(image_href).endswith('/')): LOG.warning('Attempted to validate a URL %s, however we ' 'received an HTTP Forbidden response and the ' 'url ends with trailing slash (/), suggesting ' 'non-image deploy may be in progress with ' 'a webserver which is not permitting an index ' 'to be generated. We will treat this as valid, ' 'but return the response.', image_href) return response # NOTE(TheJulia): Any file list reply will proceed past here just # fine as they are conveyed as an HTTP 200 OK response with a # server rendered HTML document payload. if response.status_code != http_client.OK: raise exception.ImageRefValidationFailed( image_href=output_url, reason=_("Got HTTP code %s instead of 200 in response " "to HEAD request.") % response.status_code) except (OSError, requests.ConnectionError, requests.RequestException) as e: raise exception.ImageRefValidationFailed(image_href=output_url, reason=str(e)) return response def download(self, image_href, image_file): """Downloads image to specified location. :param image_href: Image reference. :param image_file: File object to write data to. :raises: exception.ImageRefValidationFailed if GET request returned response code not equal to 200. :raises: exception.ImageDownloadFailed if: * IOError happened during file write; * GET request failed. """ try: verify = strutils.bool_from_string(CONF.webserver_verify_ca, strict=True) except ValueError: verify = CONF.webserver_verify_ca try: response = requests.get(image_href, stream=True, verify=verify, timeout=CONF.webserver_connection_timeout) if response.status_code != http_client.OK: raise exception.ImageRefValidationFailed( image_href=image_href, reason=_("Got HTTP code %s instead of 200 in response " "to GET request.") % response.status_code) with response.raw as input_img: shutil.copyfileobj(input_img, image_file, IMAGE_CHUNK_SIZE) except (OSError, requests.ConnectionError, requests.RequestException, IOError) as e: raise exception.ImageDownloadFailed(image_href=image_href, reason=str(e)) def show(self, image_href): """Get dictionary of image properties. :param image_href: Image reference. :raises: exception.ImageRefValidationFailed if: * HEAD request failed; * HEAD request returned response code not equal to 200; * Content-Length header not found in response to HEAD request. :returns: dictionary of image properties. It has three of them: 'size', 'updated_at' and 'properties'. 'updated_at' attribute is a naive UTC datetime object. """ response = self.validate_href(image_href) image_size = response.headers.get('Content-Length') if image_size is None: raise exception.ImageRefValidationFailed( image_href=image_href, reason=_("Cannot determine image size as there is no " "Content-Length header specified in response " "to HEAD request.")) # Parse last-modified header to return naive datetime object str_date = response.headers.get('Last-Modified') date = None if str_date: http_date_format_strings = [ '%a, %d %b %Y %H:%M:%S GMT', # RFC 822 '%A, %d-%b-%y %H:%M:%S GMT', # RFC 850 '%a %b %d %H:%M:%S %Y' # ANSI C ] for fmt in http_date_format_strings: try: date = datetime.datetime.strptime(str_date, fmt) break except ValueError: continue no_cache = 'no-store' in response.headers.get('Cache-Control', '') return { 'size': int(image_size), 'updated_at': date, 'properties': {}, 'no_cache': no_cache, } class FileImageService(BaseImageService): """Provides retrieval of disk images available locally on the conductor.""" def validate_href(self, image_href): """Validate local image reference. :param image_href: Image reference. :raises: exception.ImageRefValidationFailed if source image file doesn't exist. :returns: Path to image file if it exists. """ image_path = urlparse.urlparse(image_href).path if not os.path.isfile(image_path): raise exception.ImageRefValidationFailed( image_href=image_href, reason=_("Specified image file not found.")) return image_path def download(self, image_href, image_file): """Downloads image to specified location. :param image_href: Image reference. :param image_file: File object to write data to. :raises: exception.ImageRefValidationFailed if source image file doesn't exist. :raises: exception.ImageDownloadFailed if exceptions were raised while writing to file or creating hard link. """ source_image_path = self.validate_href(image_href) dest_image_path = image_file.name try: image_file.close() os.remove(dest_image_path) try: os.link(source_image_path, dest_image_path) except OSError as exc: LOG.debug('Could not create a link from %(src)s to %(dest)s, ' 'will copy the content instead. Error: %(exc)s.', {'src': source_image_path, 'dest': dest_image_path, 'exc': exc}) else: return # NOTE(dtantsur): starting with Python 3.8, copyfile() uses # efficient copying (i.e. sendfile) under the hood. shutil.copyfile(source_image_path, dest_image_path) except Exception as e: raise exception.ImageDownloadFailed(image_href=image_href, reason=str(e)) def show(self, image_href): """Get dictionary of image properties. :param image_href: Image reference. :raises: exception.ImageRefValidationFailed if image file specified doesn't exist. :returns: dictionary of image properties. It has three of them: 'size', 'updated_at' and 'properties'. 'updated_at' attribute is a naive UTC datetime object. """ source_image_path = self.validate_href(image_href) return { 'size': os.path.getsize(source_image_path), 'updated_at': utils.unix_file_modification_datetime( source_image_path), 'properties': {}, # No point in caching local file images 'no_cache': True, } protocol_mapping = { 'http': HttpImageService, 'https': HttpImageService, 'file': FileImageService, 'glance': GlanceImageService, } def get_image_service(image_href, client=None, context=None): """Get image service instance to download the image. :param image_href: String containing href to get image service for. :param client: Glance client to be used for download, used only if image_href is Glance href. :param context: request context, used only if image_href is Glance href. :raises: exception.ImageRefValidationFailed if no image service can handle specified href. :returns: Instance of an image service class that is able to download specified image. """ scheme = urlparse.urlparse(image_href).scheme.lower() if not scheme: if uuidutils.is_uuid_like(str(image_href)): cls = GlanceImageService else: raise exception.ImageRefValidationFailed( image_href=image_href, reason=_('Scheme-less image href is not a UUID.')) else: cls = protocol_mapping.get(scheme) if not cls: raise exception.ImageRefValidationFailed( image_href=image_href, reason=_('Image download protocol %s is not supported.' ) % scheme) if cls == GlanceImageService: return cls(client, context) return cls()