Source code for glance.async_.flows.parallel_api_image_import

# Copyright 2026 RedHat Inc.
# Copyright 2026 OpenStack Foundation
# 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.

"""Parallel multi-store import for interoperable image import.

When ``max_parallel_stores`` > 1 and more than one backend is requested,
``api_image_import.get_flow`` runs this module instead of one
``_ImportToStore`` task per store. Default config keeps the serial path.

Supported import methods: glance-direct, web-download, and glance-download.
``copy-image`` is not supported and always uses the sequential per-store path.
"""

from concurrent import futures
import os
import queue
import threading

from cryptography import exceptions as crypto_exception
from cursive import exception as cursive_exception
from cursive import signature_utils
import glance_store as store_api
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils.imageutils import format_inspector
from taskflow import task
from taskflow.types import failure as tf_failure

from glance.common import exception
from glance.common.scripts import utils as script_utils
from glance.common import store_utils
from glance.common import utils as common_utils
from glance import db as db_api
from glance.i18n import _, _LI

CONF = cfg.CONF
LOG = logging.getLogger(__name__)

_SUPPORTED_PARALLEL_IMPORT_METHODS = frozenset(
    ('glance-direct', 'web-download', 'glance-download'))

# Location metadata: marks in-progress rows owned by this import flow.
LOC_META_IMPORT_TAG = 'os_glance_parallel_import'
LOC_META_IMPORT_TAG_VALUE = 'pending'


[docs] def should_use_parallel_store_import(import_method, stores): """Return True when parallel store import should run. When False, get_flow uses the existing _ImportToStore chain. """ if import_method not in _SUPPORTED_PARALLEL_IMPORT_METHODS: return False if not CONF.enabled_backends: return False if CONF.image_import_opts.max_parallel_stores <= 1: return False if not stores: return False return len(stores) > 1
[docs] def add_parallel_store_import_tasks(flow, task_id, task_type, task_repo, action_wrapper, file_uri, stores, all_stores_must_succeed, import_method, context, image_repo): """Add staged signature verify and parallel multi-store import tasks.""" image_id = action_wrapper.image_id LOG.debug( 'Adding parallel multi-store import tasks %(task)s for image ' '%(image)s stores=%(stores)s all_stores_must_succeed=%(all)s', {'task': task_id, 'image': image_id, 'stores': ','.join(stores), 'all': all_stores_must_succeed}) flow.add(_VerifyStagedImageSignatureTask( task_id, task_type, context, image_repo, action_wrapper, file_uri, image_id, stores)) flow.add(_ParallelStoreImportTask( task_id, task_type, task_repo, action_wrapper, file_uri, stores, all_stores_must_succeed, import_method, context, image_repo))
def _placeholder_location_url(image_id, store): """Placeholder DB url until the real store location is known. ``store`` is the target backend name from the import request. Parallel import only runs with multi-backend enabled, so it is normally always set. ``None`` is not expected in this flow (it is used elsewhere for the legacy single-store default), but is normalized to ``'_'`` so the placeholder URL stays unique if it ever appears. """ return 'pending://parallel-import/%s/%s' % (image_id, store or '_') def _location_metadata(loc): """Return location metadata dict from db.image_get() location entry.""" meta = loc.get('metadata') return meta if isinstance(meta, dict) else {} def _is_in_progress_import_location(loc): """True for our pending/uploading rows (not normal active locations).""" status = loc.get('status') if status not in ('pending', 'uploading'): return False meta = _location_metadata(loc) return meta.get(LOC_META_IMPORT_TAG) == LOC_META_IMPORT_TAG_VALUE def _create_upload_verifier(context, extra_properties): """Build a signature verifier when image signature properties are set.""" if not signature_utils.should_create_verifier(extra_properties): return None return signature_utils.get_verifier( context=context, img_signature_certificate_uuid=extra_properties[ signature_utils.CERT_UUID], img_signature_hash_method=extra_properties[ signature_utils.HASH_METHOD], img_signature=extra_properties[signature_utils.SIGNATURE], img_signature_key_type=extra_properties[signature_utils.KEY_TYPE]) def _delete_staged_import_file(file_path): """Remove staged image data""" if CONF.enabled_backends: try: store_api.delete(file_path, 'os_glance_staging_store') except store_api.exceptions.NotFound: LOG.warning( 'Staged image data not found at %(path)s during cleanup', {'path': file_path}) else: path = file_path[7:] if file_path.startswith('file://') else file_path if os.path.exists(path): try: os.unlink(path) except OSError as exc: LOG.warning( 'Failed to delete staged image data at %(path)s: %(err)s', {'path': path, 'err': exc}) def _verify_staged_image_signature(context, extra_properties, staged_uri, image_id): """Verify image signature once against staged data before store copies.""" verifier = _create_upload_verifier(context, extra_properties) if verifier is None: return data_iter, _size = script_utils.get_image_data_iter(staged_uri) try: for chunk in data_iter: if chunk: verifier.update(chunk) verifier.verify() LOG.info(_LI('Successfully verified signature for image %s'), image_id) except crypto_exception.InvalidSignature: raise cursive_exception.SignatureVerificationError( _('Signature verification failed')) finally: if hasattr(data_iter, 'close'): data_iter.close() def _verify_uploaded_attribute(image, value, attribute_name): """Reject upload when staged data disagrees with image metadata.""" image_value = getattr(image, attribute_name, None) if image_value is not None and value != image_value: msg = _("%s of uploaded data is different from current " "value set on the image.") LOG.error(msg, attribute_name) raise exception.UploadException(msg % attribute_name) def _prepare_upload_data(data_iter, container_format, disk_format): """Apply in-flight format inspection for bare images (see set_data).""" if container_format == 'bare': LOG.debug('Enabling in-flight format inspection for %s', disk_format) return format_inspector.InspectWrapper(data_iter) return data_iter def _import_staged_data_to_store(context, image, staged_uri, store, hash_algo, task_repo, task_id, cancel_event): """Read staged file and write one copy to a single backend.""" image_id = image.image_id LOG.debug( 'Parallel import copying staged data to store %(store)s for image ' '%(image)s', {'store': store, 'image': image_id}) data_iter, size = script_utils.get_image_data_iter(staged_uri) upload_data = None try: if image.size is not None and image.size != size: msg = _( "Task %(task_id)s: Image size mismatch. Expected %(expected)d " "but got %(actual)d") % { 'task_id': task_id, 'expected': image.size, 'actual': size} raise exception.ImportTaskError(msg) upload_data = _prepare_upload_data( data_iter, image.container_format, image.disk_format) def _check_task_still_running(chunk_bytes, total_bytes): if cancel_event.is_set(): LOG.debug( 'Aborting import to store %(store)s for image %(image)s', {'store': store, 'image': image_id}) raise exception.TaskAbortedError() task = script_utils.get_task(task_repo, task_id) if task is None: raise exception.TaskNotFound(task_id) if task.status != 'processing': raise exception.TaskAbortedError() try: data = script_utils.CallbackIterator( common_utils.wrap_data_for_store_upload(upload_data), _check_task_still_running, min_interval=60) (location, bytes_written, checksum, os_hash, store_meta) = store_api.add_with_multihash( CONF, image_id, data, size, store, hash_algo, context=context) except format_inspector.ImageFormatError as exc: raise exception.InvalidImageData(str(exc)) locs = [{'url': location, 'metadata': store_meta or {}}] locs = store_utils.get_updated_store_location(locs, context=context) loc = locs[0] _verify_uploaded_attribute(image, bytes_written, 'size') _verify_uploaded_attribute(image, checksum, 'checksum') _verify_uploaded_attribute(image, os_hash, 'os_hash_value') LOG.debug( 'Parallel import finished store %(store)s for image %(image)s ' 'size=%(size)s', {'store': store, 'image': image_id, 'size': bytes_written}) return { 'store': store, 'url': loc['url'], 'metadata': loc['metadata'], 'size': bytes_written, 'checksum': checksum, 'os_hash_value': os_hash, } finally: stream_to_close = upload_data if upload_data is not None else data_iter if hasattr(stream_to_close, 'close'): stream_to_close.close() class _VerifyStagedImageSignatureTask(task.Task): """Verify signed image once on staging before parallel store copies.""" def __init__(self, task_id, task_type, context, image_repo, action_wrapper, file_uri, image_id, stores): self.task_id = task_id self.task_type = task_type self.context = context self.image_repo = image_repo self.action_wrapper = action_wrapper self.file_uri = file_uri self.image_id = image_id self.stores = list(stores) super(_VerifyStagedImageSignatureTask, self).__init__( name='%s-VerifyStagedSignature-%s' % (task_type, task_id)) def execute(self, file_path=None): image = self.image_repo.get(self.image_id) _verify_staged_image_signature( self.context, image.extra_properties, file_path or self.file_uri, self.image_id) def revert(self, result, file_path=None, **kwargs): """On signature failure, reset image state and drop staged data.""" if not isinstance(result, tf_failure.Failure): return LOG.warning( 'Parallel import signature verification failed for image ' '%(image)s; reverting to queued', {'image': self.image_id}) with self.action_wrapper as action: action.set_image_attribute(status='queued') action.remove_importing_stores(self.stores) action.add_failed_stores(self.stores) staged_path = file_path or self.file_uri if staged_path: _delete_staged_import_file(staged_path) class _ParallelStoreImportTask(task.Task): """Import staged image to multiple stores (bounded worker pool).""" def __init__(self, task_id, task_type, task_repo, action_wrapper, file_uri, stores, all_stores_must_succeed, import_method, context, image_repo): self.task_id = task_id self.task_type = task_type self.task_repo = task_repo self.action_wrapper = action_wrapper self.file_uri = file_uri self.stores = list(stores) self.all_stores_must_succeed = all_stores_must_succeed self.import_method = import_method self.context = context self.image_repo = image_repo self._completed_imports = [] self._import_location_row_ids = [] super(_ParallelStoreImportTask, self).__init__( name='%s-ParallelStoreImport-%s' % (task_type, task_id)) def _create_pending_location_rows(self, db, image_id): """Insert pending rows; return store_name -> location_row_id.""" location_id_by_store = {} for store in self.stores: url = _placeholder_location_url(image_id, store) meta = { 'store': store, LOC_META_IMPORT_TAG: LOC_META_IMPORT_TAG_VALUE, } common_utils.retry_on_db_lock( lambda store=store, url=url, meta=meta: db.image_location_add( self.context, image_id, {'url': url, 'metadata': meta, 'status': 'pending'})) # NOTE(abhishekk): image_location_add() does not return the new row id, # but workers must update the same row (pending -> uploading -> active) # via image_location_update(), which requires id. Reload locations and # map each target store to its pending row for parallel workers. image_from_db = common_utils.retry_on_db_lock( lambda: db.image_get(self.context, image_id)) for loc in image_from_db['locations']: if not _is_in_progress_import_location(loc): continue meta = _location_metadata(loc) store_name = meta.get('store') if store_name in self.stores: location_id_by_store[store_name] = loc['id'] self._import_location_row_ids.append(loc['id']) LOG.debug( 'Registered pending location rows for image %(image)s: ' '%(mapping)s', {'image': image_id, 'mapping': ','.join('%s=%s' % (s, i) for s, i in sorted(location_id_by_store.items()))}) return location_id_by_store def _update_location_row(self, db, image_id, row_id, url, meta, status): LOG.debug( 'Parallel import location %(row)s image %(image)s -> %(status)s', {'row': row_id, 'image': image_id, 'status': status}) common_utils.retry_on_db_lock( lambda: db.image_location_update( self.context, image_id, {'id': row_id, 'url': url, 'metadata': meta, 'status': status})) def _delete_location_rows(self, db, image_id, row_ids): for row_id in row_ids: try: common_utils.retry_on_db_lock( lambda row_id=row_id: db.image_location_delete( self.context, image_id, row_id, 'deleted')) except Exception: LOG.warning( 'Could not delete parallel-import location %(row)s for ' 'image %(image)s', {'row': row_id, 'image': image_id}, exc_info=True) def _sync_locations_from_db(self, db, image_id, action): """Merge DB location rows onto the image before repo.save.""" # NOTE(abhishekk): Parallel workers update per-store location rows # directly via image_location_* (pending -> uploading -> active). When # ImportActionWrapper reloads the image for the final save, the domain # object may still carry placeholder URLs from an earlier load. Update # each known row in place by id and append any new rows from the DB # rather than replacing action._image.locations wholesale. Assigning a # new list with changed URLs triggers StoreLocations validation # ("Original locations is not empty") under native-threaded workers. image_from_db = common_utils.retry_on_db_lock( lambda: db.image_get(self.context, image_id)) db_loc = { loc['id']: loc for loc in image_from_db['locations'] if loc.get('id')} known_ids = set() for loc in action._image.locations: row_id = loc.get('id') if row_id and row_id in db_loc: loc['url'] = db_loc[row_id]['url'] loc['metadata'] = _location_metadata(db_loc[row_id]) loc['status'] = db_loc[row_id].get('status', 'active') known_ids.add(row_id) for row_id in db_loc: if row_id not in known_ids: action._image.locations.append({ 'id': row_id, 'url': db_loc[row_id]['url'], 'metadata': _location_metadata(db_loc[row_id]), 'status': db_loc[row_id].get('status', 'active'), }) def _delete_uploaded_backend_data(self, import_result): location = { 'url': import_result['url'], 'metadata': import_result['metadata'], } image_id = self.action_wrapper.image_id LOG.debug( 'Deleting backend object for store %(store)s image %(image)s', {'store': import_result.get('store'), 'image': image_id}) try: store_utils.delete_image_location_from_backend( self.context, image_id, location) except Exception: LOG.exception( 'Failed to delete backend object for store %(store)s image ' '%(image)s', {'store': import_result.get('store'), 'image': image_id}) def _activate_image_on_first_store(self, db, image_id, import_result, hash_algo, activate_lock, image_activated): """When any store may succeed, set image active after the first one. Per-store location rows are updated via direct DB calls during parallel workers. Reconcile image locations in execute(). """ with activate_lock: if image_activated[0]: return LOG.info(_LI( 'Parallel import setting image %(image)s active after first ' 'successful store %(store)s'), {'image': image_id, 'store': import_result['store']}) common_utils.retry_on_db_lock( lambda: db.image_update( self.context, image_id, { 'status': 'active', 'size': import_result['size'], 'checksum': import_result['checksum'], 'os_hash_value': import_result['os_hash_value'], 'os_hash_algo': hash_algo, }, from_state='importing')) image_activated[0] = True def _import_to_one_store(self, store, location_id_by_store, db, image, staged_uri, hash_algo, cancel_event, successful_imports, failed_stores, imports_lock, activate_lock, image_activated): image_id = image.image_id row_id = location_id_by_store.get(store) placeholder_url = _placeholder_location_url(image_id, store) row_meta = { 'store': store, LOC_META_IMPORT_TAG: LOC_META_IMPORT_TAG_VALUE, } LOG.debug( 'Parallel import worker starting store %(store)s for image ' '%(image)s location_row_id=%(row)s', {'store': store, 'image': image_id, 'row': row_id}) if row_id is not None: self._update_location_row( db, image_id, row_id, placeholder_url, row_meta, 'uploading') try: import_result = _import_staged_data_to_store( self.context, image, staged_uri, store, hash_algo, self.task_repo, self.task_id, cancel_event) if cancel_event.is_set(): LOG.warning( 'Discarding completed upload to store %(store)s for image ' '%(image)s because parallel import was aborted', {'store': store, 'image': image_id}) self._delete_uploaded_backend_data(import_result) return if row_id is not None: self._update_location_row( db, image_id, row_id, import_result['url'], import_result['metadata'], 'active') else: common_utils.retry_on_db_lock( lambda: db.image_location_add( self.context, image_id, {'url': import_result['url'], 'metadata': import_result['metadata'], 'status': 'active'})) with imports_lock: successful_imports.append(import_result) if not self.all_stores_must_succeed: self._activate_image_on_first_store( db, image_id, import_result, hash_algo, activate_lock, image_activated) except Exception as exc: with imports_lock: failed_stores[store] = exc LOG.warning( 'Parallel import store %(store)s failed for image ' '%(image)s: %(err)s', {'store': store, 'image': image_id, 'err': exc}) if row_id is not None: try: self._delete_location_rows(db, image_id, [row_id]) except Exception: pass if self.all_stores_must_succeed: LOG.warning( 'Parallel import aborting remaining stores for image ' '%(image)s after store %(store)s failed', {'image': image_id, 'store': store}) cancel_event.set() with imports_lock: for import_result in list(successful_imports): self._delete_uploaded_backend_data(import_result) successful_imports.clear() def _store_worker(self, store_queue, location_id_by_store, db, image, staged_uri, hash_algo, cancel_event, successful_imports, failed_stores, imports_lock, activate_lock, image_activated): while not cancel_event.is_set(): try: store = store_queue.get_nowait() except queue.Empty: return try: self._import_to_one_store( store, location_id_by_store, db, image, staged_uri, hash_algo, cancel_event, successful_imports, failed_stores, imports_lock, activate_lock, image_activated) finally: store_queue.task_done() def execute(self, file_path=None): staged_uri = file_path or self.file_uri image_id = self.action_wrapper.image_id num_workers = min(CONF.image_import_opts.max_parallel_stores, len(self.stores)) image = self.image_repo.get(image_id) hash_algo = image.os_hash_algo or CONF['hashing_algorithm'] LOG.info(_LI( 'Parallel store import starting for image %(image)s: ' 'stores=%(stores)s num_workers=%(workers)s ' 'all_stores_must_succeed=%(all)s method=%(method)s'), {'image': image_id, 'stores': ','.join(self.stores), 'workers': num_workers, 'all': self.all_stores_must_succeed, 'method': self.import_method}) db = db_api.get_api() self._import_location_row_ids = [] location_id_by_store = self._create_pending_location_rows(db, image_id) store_queue = queue.Queue() for store in self.stores: store_queue.put(store) cancel_event = threading.Event() successful_imports = [] failed_stores = {} imports_lock = threading.Lock() activate_lock = threading.Lock() # NOTE(abhishekk): One-element list so worker threads can set # image_activated[0] = True in place; a plain bool would not be # shared across the thread pool and execute(). image_activated = [False] with futures.ThreadPoolExecutor(max_workers=num_workers) as pool: worker_threads = [ pool.submit( self._store_worker, store_queue, location_id_by_store, db, image, staged_uri, hash_algo, cancel_event, successful_imports, failed_stores, imports_lock, activate_lock, image_activated) for _ in range(num_workers) ] for thread in futures.as_completed(worker_threads): thread.result() if self.all_stores_must_succeed and failed_stores: first_error = next(iter(failed_stores.values())) LOG.error( 'Parallel import failed for image %(image)s: %(failed)s ' 'succeeded=%(ok)s error=%(err)s', {'image': image_id, 'failed': ','.join(sorted(failed_stores)), 'ok': ','.join(i['store'] for i in successful_imports), 'err': first_error}) for import_result in list(successful_imports): self._delete_uploaded_backend_data(import_result) successful_imports.clear() self._delete_location_rows( db, image_id, self._import_location_row_ids) self._import_location_row_ids = [] raise exception.ImportTaskError( _('Parallel import failed for image %(image)s: %(err)s') % { 'image': image_id, 'err': first_error}) if not successful_imports: LOG.error( 'Parallel import: no store succeeded for image %(image)s ' 'errors=%(errors)s', {'image': image_id, 'errors': ','.join( '%s:%s' % (s, e) for s, e in sorted(failed_stores.items()))}) self._delete_location_rows( db, image_id, self._import_location_row_ids) raise exception.ImportTaskError( _('No store import succeeded for image %s') % image_id) first_import_result = successful_imports[0] self._completed_imports = list(successful_imports) with self.action_wrapper as action: action.remove_importing_stores(self.stores) for store in failed_stores: action.add_failed_stores([store]) if self.all_stores_must_succeed or not image_activated[0]: action.set_image_attribute( status='active', size=first_import_result['size']) image = action._image image.checksum = first_import_result['checksum'] image.os_hash_value = first_import_result['os_hash_value'] image.os_hash_algo = hash_algo self._sync_locations_from_db(db, image_id, action) LOG.info(_LI( 'Parallel store import finished for image %(image)s: ' 'succeeded=%(ok)s failed=%(failed)s'), {'image': image_id, 'ok': ','.join(i['store'] for i in successful_imports), 'failed': ','.join(sorted(failed_stores)) or '(none)'}) self._import_location_row_ids = [] def revert(self, result, **kwargs): completed = getattr(self, '_completed_imports', []) if completed: LOG.warning( 'Reverting parallel store import for image %(image)s, ' 'deleting %(count)s backend object(s)', {'image': self.action_wrapper.image_id, 'count': len(completed)}) for import_result in completed: self._delete_uploaded_backend_data(import_result)