diff --git a/nova/tests/unit/virt/xenapi/image/test_glance.py b/nova/tests/unit/virt/xenapi/image/test_glance.py index 69c0197eb90a..0f6274ea1b94 100644 --- a/nova/tests/unit/virt/xenapi/image/test_glance.py +++ b/nova/tests/unit/virt/xenapi/image/test_glance.py @@ -36,6 +36,7 @@ class TestGlanceStore(stubs.XenAPITestBaseNoDB): self.store = glance.GlanceStore() self.flags(api_servers=['http://localhost:9292'], group='glance') + self.flags(use_glance_v1=True, group='glance') self.flags(connection_url='test_url', connection_password='test_pass', group='xenserver') @@ -61,6 +62,7 @@ class TestGlanceStore(stubs.XenAPITestBaseNoDB): return {'image_id': 'fake_image_uuid', 'endpoint': 'http://localhost:9292', 'sr_path': '/fake/sr/path', + 'api_version': 1, 'extra_headers': {'X-Auth-Token': 'foobar', 'X-Roles': '', 'X-Tenant-Id': 'project', diff --git a/nova/virt/xenapi/client/session.py b/nova/virt/xenapi/client/session.py index 95afa21c4eca..cc5dd3924867 100644 --- a/nova/virt/xenapi/client/session.py +++ b/nova/virt/xenapi/client/session.py @@ -69,7 +69,7 @@ class XenAPISession(object): # changed in development environments. # MAJOR VERSION: Incompatible changes with the plugins # MINOR VERSION: Compatible changes, new plguins, etc - PLUGIN_REQUIRED_VERSION = '1.3' + PLUGIN_REQUIRED_VERSION = '1.4' def __init__(self, url, user, pw): version_string = version.version_string_with_package() diff --git a/nova/virt/xenapi/fake.py b/nova/virt/xenapi/fake.py index d505a6151175..75fab189f154 100644 --- a/nova/virt/xenapi/fake.py +++ b/nova/virt/xenapi/fake.py @@ -760,7 +760,7 @@ class SessionBase(object): return base64.b64encode(zlib.compress("dom_id: %s" % dom_id)) def _plugin_nova_plugin_version_get_version(self, method, args): - return pickle.dumps("1.3") + return pickle.dumps("1.4") def _plugin_xenhost_query_gc(self, method, args): return pickle.dumps("False") diff --git a/nova/virt/xenapi/image/glance.py b/nova/virt/xenapi/image/glance.py index 6e0454c3230b..afd8eba56176 100644 --- a/nova/virt/xenapi/image/glance.py +++ b/nova/virt/xenapi/image/glance.py @@ -37,6 +37,7 @@ class GlanceStore(object): def pick_glance(kwargs): server = next(glance_api_servers) kwargs['endpoint'] = server + kwargs['api_version'] = 1 if CONF.glance.use_glance_v1 else 2 # NOTE(sdague): is the return significant here at all? return server diff --git a/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance b/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance index f2a8f90a7d06..0149b166601c 100755 --- a/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance +++ b/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance @@ -30,6 +30,11 @@ try: except ImportError: from six.moves import http_client as httplib +try: + import json +except ImportError: + import simplejson as json + import md5 # noqa import socket import urllib2 @@ -50,6 +55,15 @@ class RetryableError(Exception): pass +def _create_connection(scheme, netloc): + if scheme == 'https': + conn = httplib.HTTPSConnection(netloc) + else: + conn = httplib.HTTPConnection(netloc) + conn.connect() + return conn + + def _download_tarball_and_verify(request, staging_path): # NOTE(johngarbutt) By default, there is no timeout. # To ensure the script does not hang if we lose connection @@ -88,8 +102,11 @@ def _download_tarball_and_verify(request, staging_path): bytes_read = callback_data['bytes_read'] logging.info("Read %d bytes from %s", bytes_read, url) - # Use ETag if available, otherwise X-Image-Meta-Checksum + # Use ETag if available, otherwise content-md5(v2) or + # X-Image-Meta-Checksum(v1) etag = response.info().getheader('etag', None) + if etag is None: + etag = response.info().getheader('content-md5', None) if etag is None: etag = response.info().getheader('x-image-meta-checksum', None) @@ -107,10 +124,10 @@ def _download_tarball_and_verify(request, staging_path): logging.info(msg % {'checksum': checksum}) -def _download_tarball(sr_path, staging_path, image_id, glance_host, +def _download_tarball_v1(sr_path, staging_path, image_id, glance_host, glance_port, glance_use_ssl, extra_headers): - """Download the tarball image from Glance and extract it into the staging - area. Retry if there is any failure. + """Download the tarball image from Glance v1 and extract it into the + staging area. Retry if there is any failure. """ if glance_use_ssl: scheme = 'https' @@ -120,19 +137,20 @@ def _download_tarball(sr_path, staging_path, image_id, glance_host, endpoint = "%(scheme)s://%(glance_host)s:%(glance_port)d" % { 'scheme': scheme, 'glance_host': glance_host, 'glance_port': glance_port} - _download_tarball_by_url(sr_path, staging_path, image_id, - endpoint, extra_headers) + _download_tarball_by_url_v1(sr_path, staging_path, image_id, + endpoint, extra_headers) -def _download_tarball_by_url(sr_path, staging_path, image_id, - glance_endpoint, extra_headers): - """Download the tarball image from Glance and extract it into the staging - area. Retry if there is any failure. +def _download_tarball_by_url_v1( + sr_path, staging_path, image_id, glance_endpoint, extra_headers): + """Download the tarball image from Glance v1 and extract it into the + staging area. Retry if there is any failure. """ - url = ("%(glance_endpoint)s/v1/images/%(image_id)s" % { + + url = "%(glance_endpoint)s/v1/images/%(image_id)s" % { 'glance_endpoint': glance_endpoint, - 'image_id': image_id}) - logging.info("Downloading %s" % url) + 'image_id': image_id} + logging.info("Downloading %s with glance v1 api" % url) request = urllib2.Request(url, headers=extra_headers) try: @@ -142,7 +160,26 @@ def _download_tarball_by_url(sr_path, staging_path, image_id, raise -def _upload_tarball(staging_path, image_id, glance_host, glance_port, +def _download_tarball_by_url_v2( + sr_path, staging_path, image_id, glance_endpoint, extra_headers): + """Download the tarball image from Glance v2 and extract it into the + staging area. Retry if there is any failure. + """ + + url = "%(glance_endpoint)s/v2/images/%(image_id)s/file" % { + 'glance_endpoint': glance_endpoint, + 'image_id': image_id} + logging.debug("Downloading %s with glance v2 api" % url) + + request = urllib2.Request(url, headers=extra_headers) + try: + _download_tarball_and_verify(request, staging_path) + except Exception: + logging.exception('Failed to retrieve %(url)s' % {'url': url}) + raise + + +def _upload_tarball_v1(staging_path, image_id, glance_host, glance_port, glance_use_ssl, extra_headers, properties): if glance_use_ssl: scheme = 'https' @@ -150,15 +187,14 @@ def _upload_tarball(staging_path, image_id, glance_host, glance_port, scheme = 'http' url = '%s://%s:%s' % (scheme, glance_host, glance_port) - _upload_tarball_by_url(staging_path, image_id, url, - extra_headers, properties) + _upload_tarball_by_url_v1(staging_path, image_id, url, + extra_headers, properties) -def _upload_tarball_by_url(staging_path, image_id, glance_endpoint, - extra_headers, properties): - """Upload an image to Glance. - - Create a tarball of the image and then stream that into Glance +def _upload_tarball_by_url_v1(staging_path, image_id, glance_endpoint, + extra_headers, properties): + """ + Create a tarball of the image and then stream that into Glance v1 using chunked-transfer-encoded HTTP. """ # NOTE(johngarbutt) By default, there is no timeout. @@ -167,8 +203,10 @@ def _upload_tarball_by_url(staging_path, image_id, glance_endpoint, # This is here so there is no chance the timeout out has # been adjusted by other library calls. socket.setdefaulttimeout(SOCKET_TIMEOUT_SECONDS) + logging.debug("Uploading image %s with glance v1 api" + % image_id) - url = '%(glance_endpoint)s/v1/images/%(image_id)s' % { + url = "%(glance_endpoint)s/v1/images/%(image_id)s" % { 'glance_endpoint': glance_endpoint, 'image_id': image_id} logging.info("Writing image data to %s" % url) @@ -181,17 +219,13 @@ def _upload_tarball_by_url(staging_path, image_id, glance_endpoint, parts = urlparse(url) try: - if parts[0] == 'https': - conn = httplib.HTTPSConnection(parts[1]) - else: - conn = httplib.HTTPConnection(parts[1]) - conn.connect() + conn = _create_connection(parts[0], parts[1]) except Exception, error: # noqa logging.exception('Failed to connect %(url)s' % {'url': url}) raise RetryableError(error) try: - validate_image_status_before_upload(conn, url, extra_headers) + validate_image_status_before_upload_v1(conn, url, extra_headers) try: # NOTE(sirp): httplib under python2.4 won't accept @@ -268,6 +302,131 @@ def _upload_tarball_by_url(staging_path, image_id, glance_endpoint, conn.close() +def _update_image_meta_v2(conn, image_id, extra_headers, properties): + # NOTE(sirp): There is some confusion around OVF. Here's a summary + # of where we currently stand: + # 1. OVF as a container format is misnamed. We really should be + # using OVA since that is the name for the container format; + # OVF is the standard applied to the manifest file contained + # within. + # 2. We're currently uploading a vanilla tarball. In order to be + # OVF/OVA compliant, we'll need to embed a minimal OVF + # manifest as the first file. + body = [ + {"path": "/container_format", "value": "ovf", "op": "add"}, + {"path": "/disk_format", "value": "vhd", "op": "add"}, + {"path": "/visibility", "value": "private", "op": "add"}] + + headers = {'Content-Type': 'application/openstack-images-v2.1-json-patch'} + headers.update(**extra_headers) + + for key, value in properties.iteritems(): + prop = {"path": "/%s" % key.replace('_', '-'), + "value": key, + "op": "add"} + body.append(prop) + body = json.dumps(body) + conn.request('PATCH', '/v2/images/%s' % image_id, body=body, headers=headers) + resp = conn.getresponse() + resp.read() + + if resp.status == httplib.OK: + return + logging.error("Image meta was not updated. Status: %s, Reason: %s" % + (resp.status, resp.reason)) + + +def _upload_tarball_by_url_v2(staging_path, image_id, glance_endpoint, + extra_headers, properties): + """ + Create a tarball of the image and then stream that into Glance v2 + using chunked-transfer-encoded HTTP. + """ + # NOTE(johngarbutt) By default, there is no timeout. + # To ensure the script does not hang if we lose connection + # to glance, we add this socket timeout. + # This is here so there is no chance the timeout out has + # been adjusted by other library calls. + socket.setdefaulttimeout(SOCKET_TIMEOUT_SECONDS) + logging.debug("Uploading imaged %s with glance v2 api" + % image_id) + + url = "%(glance_endpoint)s/v2/images/%(image_id)s/file" % { + 'glance_endpoint': glance_endpoint, + 'image_id': image_id} + + # NOTE(sdague): this is python 2.4, which means urlparse returns a + # tuple, not a named tuple. + # 0 - scheme + # 1 - host:port (aka netloc) + # 2 - path + parts = urlparse(url) + + try: + conn = _create_connection(parts[0], parts[1]) + except Exception, error: + raise RetryableError(error) + + try: + _update_image_meta_v2(conn, image_id, extra_headers, properties) + + validate_image_status_before_upload_v2(conn, url, extra_headers) + + try: + conn.connect() + # NOTE(sirp): httplib under python2.4 won't accept + # a file-like object to request + conn.putrequest('PUT', parts[2]) + + headers = { + 'content-type': 'application/octet-stream', + 'transfer-encoding': 'chunked'} + + headers.update(**extra_headers) + + for header, value in headers.items(): + conn.putheader(header, value) + conn.endheaders() + except Exception, error: # noqa + logging.exception('Failed to upload %(url)s' % {'url': url}) + raise RetryableError(error) + + callback_data = {'bytes_written': 0} + + def send_chunked_transfer_encoded(chunk): + chunk_len = len(chunk) + callback_data['bytes_written'] += chunk_len + try: + conn.send("%x\r\n%s\r\n" % (chunk_len, chunk)) + except Exception, error: + logging.exception('Failed to upload when sending chunks') + raise RetryableError(error) + + compression_level = properties.get('xenapi_image_compression_level') + + utils.create_tarball( + None, staging_path, callback=send_chunked_transfer_encoded, + compression_level=compression_level) + + send_chunked_transfer_encoded('') # Chunked-Transfer terminator + + bytes_written = callback_data['bytes_written'] + logging.info("Wrote %d bytes to %s" % (bytes_written, url)) + + resp = conn.getresponse() + if resp.status == httplib.NO_CONTENT: + return + + logging.error("Unexpected response while writing image data to %s: " + "Response Status: %i, Response body: %s" + % (url, resp.status, resp.read())) + + check_resp_status_and_retry(resp, image_id, url) + + finally: + conn.close() + + def check_resp_status_and_retry(resp, image_id, url): # Note(Jesse): This branch sorts errors into those that are permanent, # those that are ephemeral, and those that are unexpected. @@ -320,7 +479,7 @@ def check_resp_status_and_retry(resp, image_id, url): % (resp.status, image_id, url)) -def validate_image_status_before_upload(conn, url, extra_headers): +def validate_image_status_before_upload_v1(conn, url, extra_headers): try: parts = urlparse(url) path = parts[2] @@ -379,17 +538,71 @@ def validate_image_status_before_upload(conn, url, extra_headers): 'image_status': image_status}) +def validate_image_status_before_upload_v2(conn, url, extra_headers): + try: + parts = urlparse(url) + path = parts[2] + image_id = path.split('/')[-2] + # NOTE(nikhil): Attempt to determine if the Image has a status + # of 'queued'. Because data will continued to be sent to Glance + # until it has a chance to check the Image state, discover that + # it is not 'active' and send back a 409. Hence, the data will be + # unnecessarily buffered by Glance. This wastes time and bandwidth. + # LP bug #1202785 + + conn.request('GET', '/v2/images/%s' % image_id, headers=extra_headers) + get_resp = conn.getresponse() + except Exception, error: # noqa + logging.exception('Failed to GET the image %(image_id)s while ' + 'checking image status before attempting to ' + 'upload %(url)s' % {'image_id': image_id, + 'url': url}) + raise RetryableError(error) + + if get_resp.status != httplib.OK: + logging.error("Unexpected response while doing a GET call " + "to image %s , url = %s , Response Status: " + "%i" % (image_id, url, get_resp.status)) + + check_resp_status_and_retry(get_resp, image_id, url) + + else: + body = json.loads(get_resp.read()) + image_status = body['status'] + if image_status not in ('queued', ): + err_msg = ('Cannot upload data for image %(image_id)s as the ' + 'image status is %(image_status)s' % + {'image_id': image_id, 'image_status': image_status}) + logging.exception(err_msg) + raise PluginError("Got Permanent Error while uploading image " + "[%s] to glance [%s]. " + "Message: %s" % (image_id, url, + err_msg)) + else: + logging.info('Found image %(image_id)s in status ' + '%(image_status)s. Attempting to ' + 'upload.' % {'image_id': image_id, + 'image_status': image_status}) + get_resp.read() + + def download_vhd2(session, image_id, endpoint, - uuid_stack, sr_path, extra_headers): - """Download an image from Glance, unbundle it, and then deposit the VHDs - into the storage repository + uuid_stack, sr_path, extra_headers, api_version=1): + """Download an image from Glance v2, unbundle it, and then deposit the + VHDs into the storage repository. """ staging_path = utils.make_staging_area(sr_path) try: # Download tarball into staging area and extract it - _download_tarball_by_url( - sr_path, staging_path, image_id, - endpoint, extra_headers) + # TODO(mfedosin): remove this check when v1 is deprecated. + if api_version == 1: + _download_tarball_by_url_v1( + sr_path, staging_path, image_id, + endpoint, extra_headers) + else: + _download_tarball_by_url_v2( + sr_path, staging_path, image_id, + endpoint, extra_headers) # Move the VHDs from the staging area into the storage repository return utils.import_vhds(sr_path, staging_path, uuid_stack) @@ -397,15 +610,21 @@ def download_vhd2(session, image_id, endpoint, utils.cleanup_staging_area(staging_path) -def upload_vhd2(session, vdi_uuids, image_id, - endpoint, sr_path, extra_headers, properties): - """Bundle the VHDs comprising an image and then stream them into Glance. +def upload_vhd2(session, vdi_uuids, image_id, endpoint, sr_path, + extra_headers, properties, api_version=1): + """Bundle the VHDs comprising an image and then stream them into + Glance. """ staging_path = utils.make_staging_area(sr_path) try: utils.prepare_staging_area(sr_path, staging_path, vdi_uuids) - _upload_tarball_by_url(staging_path, image_id, - endpoint, extra_headers, properties) + # TODO(mfedosin): remove this check when v1 is deprecated. + if api_version == 1: + _upload_tarball_by_url_v1(staging_path, image_id, + endpoint, extra_headers, properties) + else: + _upload_tarball_by_url_v2(staging_path, image_id, + endpoint, extra_headers, properties) finally: utils.cleanup_staging_area(staging_path) diff --git a/plugins/xenserver/xenapi/etc/xapi.d/plugins/nova_plugin_version b/plugins/xenserver/xenapi/etc/xapi.d/plugins/nova_plugin_version index 8ef21eaaa569..bec3a96f88a5 100755 --- a/plugins/xenserver/xenapi/etc/xapi.d/plugins/nova_plugin_version +++ b/plugins/xenserver/xenapi/etc/xapi.d/plugins/nova_plugin_version @@ -29,7 +29,8 @@ import utils # 1.1 - New call to check GC status # 1.2 - Added support for pci passthrough devices # 1.3 - Add vhd2 functions for doing glance operations by url -PLUGIN_VERSION = "1.3" +# 1.4 - Add support of Glance v2 api +PLUGIN_VERSION = "1.4" def get_version(session):