murano/murano/engine/package_loader.py

638 lines
24 KiB
Python

# Copyright (c) 2014 Mirantis Inc.
#
# 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 collections
import itertools
import os
import os.path
import shutil
import sys
import tempfile
import uuid
import eventlet
from muranoclient.common import exceptions as muranoclient_exc
from muranoclient.glance import client as glare_client
import muranoclient.v1.client as muranoclient
from oslo_config import cfg
from oslo_log import log as logging
from oslo_log import versionutils
from oslo_utils import fileutils
from murano.common import auth_utils
from murano.common import utils
from murano.dsl import constants
from murano.dsl import exceptions
from murano.dsl import helpers
from murano.dsl import package_loader
from murano.engine import murano_package
from murano.engine.system import system_objects
from murano.engine import yaql_yaml_loader
from murano.packages import exceptions as pkg_exc
from murano.packages import load_utils
from murano import utils as m_utils
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
download_mem_locks = collections.defaultdict(m_utils.ReaderWriterLock)
usage_mem_locks = collections.defaultdict(m_utils.ReaderWriterLock)
class ApiPackageLoader(package_loader.MuranoPackageLoader):
def __init__(self, execution_session, root_loader=None):
self._cache_directory = self._get_cache_directory()
self._class_cache = {}
self._package_cache = {}
self._root_loader = root_loader or self
self._execution_session = execution_session
self._last_glare_token = None
self._glare_client = None
self._murano_client = None
self._murano_client_session = None
self._mem_locks = []
self._ipc_locks = []
self._downloaded = []
self._fixations = collections.defaultdict(set)
self._new_fixations = collections.defaultdict(set)
def _get_glare_client(self):
glare_settings = CONF.glare
session = auth_utils.get_client_session(self._execution_session)
token = session.auth.get_token(session)
if self._last_glare_token != token:
self._last_glare_token = token
self._glare_client = None
if self._glare_client is None:
url = glare_settings.url
if not url:
url = session.get_endpoint(
service_type='artifact',
interface=glare_settings.endpoint_type,
region_name=CONF.home_region)
# TODO(gyurco): use auth_utils.get_session_client_parameters
self._glare_client = glare_client.Client(
endpoint=url, token=token,
insecure=glare_settings.insecure,
key_file=glare_settings.keyfile or None,
ca_file=glare_settings.cafile or None,
cert_file=glare_settings.certfile or None,
type_name='murano',
type_version=1)
return self._glare_client
@property
def client(self):
last_glare_client = self._glare_client
if CONF.engine.packages_service in ['glance', 'glare']:
if CONF.engine.packages_service == 'glance':
versionutils.report_deprecated_feature(
LOG, "'glance' packages_service option has been renamed "
"to 'glare', please update your configuration")
artifacts_client = self._get_glare_client()
else:
artifacts_client = None
if artifacts_client != last_glare_client:
self._murano_client = None
if not self._murano_client:
parameters = auth_utils.get_session_client_parameters(
service_type='application-catalog',
execution_session=self._execution_session,
conf='murano'
)
self._murano_client = muranoclient.Client(
artifacts_client=artifacts_client, **parameters)
return self._murano_client
def load_class_package(self, class_name, version_spec):
packages = self._class_cache.get(class_name)
if packages:
version = version_spec.select(packages.keys())
if version:
return packages[version]
filter_opts = {'class_name': class_name,
'version': helpers.breakdown_spec_to_query(
version_spec)}
try:
package_definition = self._get_definition(filter_opts)
self._lock_usage(package_definition)
except LookupError:
exc_info = sys.exc_info()
utils.reraise(exceptions.NoPackageForClassFound,
exceptions.NoPackageForClassFound(class_name),
exc_info[2])
return self._to_dsl_package(
self._get_package_by_definition(package_definition))
def load_package(self, package_name, version_spec):
fixed_versions = self._fixations[package_name]
version = version_spec.select(fixed_versions)
if version:
version_spec = helpers.parse_version_spec(version)
packages = self._package_cache.get(package_name)
if packages:
version = version_spec.select(packages.keys())
if version:
return packages[version]
filter_opts = {'fqn': package_name,
'version': helpers.breakdown_spec_to_query(
version_spec)}
try:
package_definition = self._get_definition(filter_opts)
self._lock_usage(package_definition)
except LookupError:
exc_info = sys.exc_info()
utils.reraise(exceptions.NoPackageFound,
exceptions.NoPackageFound(package_name),
exc_info[2])
else:
package = self._get_package_by_definition(package_definition)
self._fixations[package_name].add(package.version)
self._new_fixations[package_name].add(package.version)
return self._to_dsl_package(package)
def register_package(self, package):
for name in package.classes:
self._class_cache.setdefault(name, {})[package.version] = package
self._package_cache.setdefault(package.name, {})[
package.version] = package
@staticmethod
def _get_cache_directory():
base_directory = (
CONF.engine.packages_cache or
os.path.join(tempfile.gettempdir(), 'murano-packages-cache')
)
if CONF.engine.enable_packages_cache:
directory = os.path.abspath(base_directory)
else:
directory = os.path.abspath(os.path.join(base_directory,
uuid.uuid4().hex))
if not os.path.isdir(directory):
fileutils.ensure_tree(directory)
LOG.debug('Cache for package loader is located at: {dir}'.format(
dir=directory))
return directory
def _get_definition(self, filter_opts):
filter_opts['catalog'] = True
try:
packages = list(self.client.packages.filter(
**filter_opts))
if len(packages) > 1:
LOG.debug('Ambiguous package resolution: more than 1 package '
'found for query "{opts}", will resolve based on the'
' ownership'.format(opts=filter_opts))
return self._get_best_package_match(packages)
elif len(packages) == 1:
return packages[0]
else:
LOG.debug('There are no packages matching filter '
'{opts}'.format(opts=filter_opts))
raise LookupError()
except muranoclient_exc.HTTPException:
LOG.debug('Failed to get package definition from repository')
raise LookupError()
def _to_dsl_package(self, app_package):
dsl_package = murano_package.MuranoPackage(
self._root_loader, app_package)
for name in app_package.classes:
dsl_package.register_class(
(lambda cls: lambda: get_class(app_package, cls))(name),
name)
if app_package.full_name == constants.CORE_LIBRARY:
system_objects.register(dsl_package)
self.register_package(dsl_package)
return dsl_package
def _get_package_by_definition(self, package_def):
package_id = package_def.id
package_directory = os.path.join(
self._cache_directory,
package_def.fully_qualified_name,
getattr(package_def, 'version', '0.0.0'),
package_id)
if os.path.isdir(package_directory):
try:
return load_utils.load_from_dir(package_directory)
except pkg_exc.PackageLoadError:
LOG.exception('Unable to load package from cache. Clean-up.')
shutil.rmtree(package_directory, ignore_errors=True)
# the package is not yet in cache, let's try to download it.
download_lock_path = os.path.join(
self._cache_directory, '{}_download.lock'.format(package_id))
download_ipc_lock = m_utils.ExclusiveInterProcessLock(
path=download_lock_path, sleep_func=eventlet.sleep)
with download_mem_locks[package_id].write_lock(), download_ipc_lock:
# NOTE(kzaitsev):
# in case there were 2 concurrent threads/processes one might have
# already downloaded this package. Check before trying to download
if os.path.isdir(package_directory):
try:
return load_utils.load_from_dir(package_directory)
except pkg_exc.PackageLoadError:
LOG.error('Unable to load package from cache. Clean-up.')
shutil.rmtree(package_directory, ignore_errors=True)
# attempt the download itself
try:
LOG.debug("Attempting to download package {} {}".format(
package_def.fully_qualified_name, package_id))
package_data = self.client.packages.download(package_id)
except muranoclient_exc.HTTPException as e:
msg = 'Error loading package id {0}: {1}'.format(
package_id, str(e)
)
exc_info = sys.exc_info()
utils.reraise(pkg_exc.PackageLoadError,
pkg_exc.PackageLoadError(msg),
exc_info[2])
package_file = None
try:
with tempfile.NamedTemporaryFile(delete=False) as package_file:
package_file.write(package_data)
with load_utils.load_from_file(
package_file.name,
target_dir=package_directory,
drop_dir=False) as app_package:
LOG.info(
"Successfully downloaded and unpacked "
"package {} {}".format(
package_def.fully_qualified_name, package_id))
self._downloaded.append(app_package)
self.try_cleanup_cache(
os.path.split(package_directory)[0],
current_id=package_id)
return app_package
except IOError:
msg = 'Unable to extract package data for %s' % package_id
exc_info = sys.exc_info()
utils.reraise(pkg_exc.PackageLoadError,
pkg_exc.PackageLoadError(msg),
exc_info[2])
finally:
try:
if package_file:
os.remove(package_file.name)
except OSError:
pass
def try_cleanup_cache(self, package_directory=None, current_id=None):
"""Attempts to cleanup cache in a given directory.
:param package_directory: directory containing cached packages
:param current_id: optional id of the package to exclude from the list
of deleted packages
"""
if not package_directory:
return
try:
pkg_ids_listed = set(os.listdir(package_directory))
except OSError:
# No directory for this package, probably someone
# already deleted everything. Anyway nothing to delete
return
# if current_id was given: ensure it's not checked for removal
pkg_ids_listed -= {current_id}
for pkg_id in pkg_ids_listed:
stale_directory = os.path.join(
package_directory,
pkg_id)
if not os.path.isdir(package_directory):
continue
usage_lock_path = os.path.join(
self._cache_directory,
'{}_usage.lock'.format(pkg_id))
ipc_lock = m_utils.ExclusiveInterProcessLock(
path=usage_lock_path, sleep_func=eventlet.sleep)
try:
with usage_mem_locks[pkg_id].write_lock(False) as acquired:
if not acquired:
# the package is in use by other deployment in this
# process will do nothing, someone else would delete it
continue
acquired_ipc_lock = ipc_lock.acquire(blocking=False)
if not acquired_ipc_lock:
# the package is in use by other deployment in another
# process, will do nothing, someone else would delete
continue
shutil.rmtree(stale_directory,
ignore_errors=True)
ipc_lock.release()
for lock_type in ('usage', 'download'):
lock_path = os.path.join(
self._cache_directory,
'{}_{}.lock'.format(pkg_id, lock_type))
try:
os.remove(lock_path)
except OSError:
LOG.warning("Couldn't delete lock file: "
"{}".format(lock_path))
except RuntimeError:
# couldn't upgrade read lock to write-lock. go on.
continue
def _get_best_package_match(self, packages):
public = None
other = []
for package in packages:
if package.owner_id == self._execution_session.project_id:
return package
elif package.is_public:
public = package
else:
other.append(package)
if public is not None:
return public
elif other:
return other[0]
def _lock_usage(self, package_definition):
"""Locks package for usage"""
# do not lock anything if we do not persist packages on disk
if not CONF.engine.enable_packages_cache:
return
package_id = package_definition.id
# A work around the fact that read_lock only supports `with` syntax.
mem_lock = _with_to_generator(
usage_mem_locks[package_id].read_lock())
usage_lock_path = os.path.join(self._cache_directory,
'{}_usage.lock'.format(package_id))
ipc_lock = m_utils.SharedInterProcessLock(
path=usage_lock_path,
sleep_func=eventlet.sleep
)
ipc_lock = _with_to_generator(ipc_lock)
next(mem_lock)
next(ipc_lock)
self._mem_locks.append(mem_lock)
self._ipc_locks.append(ipc_lock)
def import_fixation_table(self, fixations):
self._fixations = deserialize_package_fixations(fixations)
def export_fixation_table(self):
return serialize_package_fixations(self._fixations)
def compact_fixation_table(self):
self._fixations = self._new_fixations.copy()
def cleanup(self):
"""Cleans up any lock we had acquired and removes any stale packages"""
if not CONF.engine.enable_packages_cache:
shutil.rmtree(self._cache_directory, ignore_errors=True)
return
for lock in itertools.chain(self._mem_locks, self._ipc_locks):
try:
next(lock)
except StopIteration:
continue
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return False
class DirectoryPackageLoader(package_loader.MuranoPackageLoader):
def __init__(self, base_path, root_loader=None):
self._base_path = base_path
self._packages_by_class = {}
self._packages_by_name = {}
self._loaded_packages = set()
self._root_loader = root_loader or self
self._fixations = collections.defaultdict(set)
self._new_fixations = collections.defaultdict(set)
self._build_index()
def _build_index(self):
for folder in self.search_package_folders(self._base_path):
try:
package = load_utils.load_from_dir(folder)
dsl_package = murano_package.MuranoPackage(
self._root_loader, package)
for class_name in package.classes:
dsl_package.register_class(
(lambda pkg, cls:
lambda: get_class(pkg, cls))(package, class_name),
class_name
)
if dsl_package.name == constants.CORE_LIBRARY:
system_objects.register(dsl_package)
self.register_package(dsl_package)
except pkg_exc.PackageLoadError:
LOG.info('Unable to load package from path: {0}'.format(
folder))
continue
LOG.info('Loaded package from path {0}'.format(folder))
def import_fixation_table(self, fixations):
self._fixations = deserialize_package_fixations(fixations)
def export_fixation_table(self):
return serialize_package_fixations(self._fixations)
def compact_fixation_table(self):
self._fixations = self._new_fixations.copy()
def load_class_package(self, class_name, version_spec):
packages = self._packages_by_class.get(class_name)
if not packages:
raise exceptions.NoPackageForClassFound(class_name)
version = version_spec.select(packages.keys())
if not version:
raise exceptions.NoPackageForClassFound(class_name)
return packages[version]
def load_package(self, package_name, version_spec):
fixed_versions = self._fixations[package_name]
version = version_spec.select(fixed_versions)
if version:
version_spec = helpers.parse_version_spec(version)
packages = self._packages_by_name.get(package_name)
if not packages:
raise exceptions.NoPackageFound(package_name)
version = version_spec.select(packages.keys())
if not version:
raise exceptions.NoPackageFound(package_name)
self._fixations[package_name].add(version)
self._new_fixations[package_name].add(version)
return packages[version]
def register_package(self, package):
for c in package.classes:
self._packages_by_class.setdefault(c, {})[
package.version] = package
self._packages_by_name.setdefault(package.name, {})[
package.version] = package
@property
def packages(self):
for package_versions in self._packages_by_name.values():
for package in package_versions.values():
yield package
@staticmethod
def split_path(path):
tail = True
while tail:
path, tail = os.path.split(path)
if tail:
yield path
@classmethod
def search_package_folders(cls, path):
packages = set()
for folder, _, files in os.walk(path):
if 'manifest.yaml' in files:
found = False
for part in cls.split_path(folder):
if part in packages:
found = True
break
if not found:
packages.add(folder)
yield folder
def cleanup(self):
"""A stub for possible cleanup"""
pass
class CombinedPackageLoader(package_loader.MuranoPackageLoader):
def __init__(self, execution_session, root_loader=None):
root_loader = root_loader or self
self.api_loader = ApiPackageLoader(execution_session, root_loader)
self.directory_loaders = []
for folder in CONF.engine.load_packages_from:
if os.path.exists(folder):
self.directory_loaders.append(DirectoryPackageLoader(
folder, root_loader))
def load_package(self, package_name, version_spec):
for loader in self.directory_loaders:
try:
return loader.load_package(package_name, version_spec)
except exceptions.NoPackageFound:
continue
return self.api_loader.load_package(
package_name, version_spec)
def load_class_package(self, class_name, version_spec):
for loader in self.directory_loaders:
try:
return loader.load_class_package(class_name, version_spec)
except exceptions.NoPackageForClassFound:
continue
return self.api_loader.load_class_package(
class_name, version_spec)
def register_package(self, package):
self.api_loader.register_package(package)
def export_fixation_table(self):
result = deserialize_package_fixations(
self.api_loader.export_fixation_table())
for loader in self.directory_loaders:
fixations = deserialize_package_fixations(
loader.export_fixation_table())
for key, value in fixations.items():
result[key].update(value)
return serialize_package_fixations(result)
def import_fixation_table(self, fixations):
self.api_loader.import_fixation_table(fixations)
for loader in self.directory_loaders:
loader.import_fixation_table(fixations)
def compact_fixation_table(self):
self.api_loader.compact_fixation_table()
for loader in self.directory_loaders:
loader.compact_fixation_table()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return False
def cleanup(self):
"""Calls cleanup method of all loaders we combine"""
self.api_loader.cleanup()
for d_loader in self.directory_loaders:
d_loader.cleanup()
def get_class(package, name):
version = package.runtime_version
loader = yaql_yaml_loader.get_loader(version)
contents, file_id = package.get_class(name)
return loader(contents, file_id)
def _with_to_generator(context_obj):
with context_obj as obj:
yield obj
yield
def deserialize_package_fixations(fixations):
result = collections.defaultdict(set)
for name, versions in fixations.items():
for version in versions:
result[name].add(helpers.parse_version(version))
return result
def serialize_package_fixations(fixations):
return {
name: list(str(v) for v in versions)
for name, versions in fixations.items()
}