python-fuelclient/fuelclient/objects/plugins.py

527 lines
16 KiB
Python

# Copyright 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 abc
import json
import os
import shutil
import subprocess
import sys
import tarfile
from distutils.version import StrictVersion
import six
import yaml
from fuelclient.cli import error
from fuelclient.objects import base
from fuelclient import utils
IS_MASTER = None
FUEL_PACKAGE = 'fuel'
PLUGINS_PATH = '/var/www/nailgun/plugins/'
METADATA_MASK = '/var/www/nailgun/plugins/*/metadata.yaml'
def raise_error_if_not_master():
"""Raises error if it's not Fuel master
:raises: error.WrongEnvironmentError
"""
msg_tail = 'Action can be performed from Fuel master node only.'
global IS_MASTER
if IS_MASTER is None:
IS_MASTER = False
rpm_exec = utils.find_exec('rpm')
if not rpm_exec:
msg = 'Command "rpm" not found. ' + msg_tail
raise error.WrongEnvironmentError(msg)
command = [rpm_exec, '-q', FUEL_PACKAGE]
p = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
p.communicate()
if p.poll() == 0:
IS_MASTER = True
if not IS_MASTER:
msg = 'Package "fuel" is not installed. ' + msg_tail
raise error.WrongEnvironmentError(msg)
def master_only(f):
"""Decorator for the method, which raises error, if method
is called on the node which is not Fuel master
"""
@six.wraps(f)
def print_message(*args, **kwargs):
raise_error_if_not_master()
return f(*args, **kwargs)
return print_message
@six.add_metaclass(abc.ABCMeta)
class BasePlugin(object):
@abc.abstractmethod
def install(cls, plugin_path, force=False):
"""Installs plugin package
"""
@abc.abstractmethod
def update(cls, plugin_path):
"""Updates the plugin
"""
@abc.abstractmethod
def remove(cls, plugin_name, plugin_version):
"""Removes the plugin from file system
"""
@abc.abstractmethod
def downgrade(cls, plugin_path):
"""Downgrades the plugin
"""
@abc.abstractmethod
def name_from_file(cls, file_path):
"""Retrieves name from plugin package
"""
@abc.abstractmethod
def version_from_file(cls, file_path):
"""Retrieves version from plugin package
"""
class PluginV1(BasePlugin):
metadata_config = 'metadata.yaml'
def deprecated(f):
"""Prints deprecation warning for old plugins
"""
@six.wraps(f)
def print_message(*args, **kwargs):
six.print_(
'DEPRECATION WARNING: The plugin has old 1.0 package format, '
'this format does not support many features, such as '
'plugins updates, find plugin in new format or migrate '
'and rebuild this one.', file=sys.stderr)
return f(*args, **kwargs)
return print_message
@classmethod
@master_only
@deprecated
def install(cls, plugin_path, force=False):
plugin_tar = tarfile.open(plugin_path, 'r')
try:
plugin_tar.extractall(PLUGINS_PATH)
finally:
plugin_tar.close()
@classmethod
@master_only
@deprecated
def remove(cls, plugin_name, plugin_version):
plugin_path = os.path.join(
PLUGINS_PATH, '{0}-{1}'.format(plugin_name, plugin_version))
shutil.rmtree(plugin_path)
@classmethod
def update(cls, _):
raise error.BadDataException(
'Update action is not supported for old plugins with '
'package version "1.0.0", you can install your plugin '
'or use newer plugin format.')
@classmethod
def downgrade(cls, _):
raise error.BadDataException(
'Downgrade action is not supported for old plugins with '
'package version "1.0.0", you can install your plugin '
'or use newer plugin format.')
@classmethod
def name_from_file(cls, file_path):
"""Retrieves plugin name from plugin archive.
:param str plugin_path: path to the plugin
:returns: plugin name
"""
return cls._get_metadata(file_path)['name']
@classmethod
def version_from_file(cls, file_path):
"""Retrieves plugin version from plugin archive.
:param str plugin_path: path to the plugin
:returns: plugin version
"""
return cls._get_metadata(file_path)['version']
@classmethod
def _get_metadata(cls, plugin_path):
"""Retrieves metadata from plugin archive
:param str plugin_path: path to the plugin
:returns: metadata from the plugin
"""
plugin_tar = tarfile.open(plugin_path, 'r')
try:
for member_name in plugin_tar.getnames():
if cls.metadata_config in member_name:
return yaml.load(
plugin_tar.extractfile(member_name).read())
finally:
plugin_tar.close()
class PluginV2(BasePlugin):
@classmethod
@master_only
def install(cls, plugin_path, force=False):
if force:
utils.exec_cmd(
'yum -y install --disablerepo=\'*\' {0} || '
'yum -y reinstall --disablerepo=\'*\' {0}'
.format(plugin_path))
else:
utils.exec_cmd('yum -y install --disablerepo=\'*\' {0}'
.format(plugin_path))
@classmethod
@master_only
def remove(cls, name, version):
rpm_name = '{0}-{1}'.format(name, utils.major_plugin_version(version))
utils.exec_cmd('yum -y remove {0}'.format(rpm_name))
@classmethod
@master_only
def update(cls, plugin_path):
utils.exec_cmd('yum -y update {0}'.format(plugin_path))
@classmethod
@master_only
def downgrade(cls, plugin_path):
utils.exec_cmd('yum -y downgrade {0}'.format(plugin_path))
@classmethod
def name_from_file(cls, file_path):
"""Retrieves plugin name from RPM. RPM name contains
the version of the plugin, which should be removed.
:param str file_path: path to rpm file
:returns: name of the plugin
"""
for line in utils.exec_cmd_iterator(
"rpm -qp --queryformat '%{{name}}' {0}".format(file_path)):
name = line
break
return cls._remove_major_plugin_version(name)
@classmethod
def version_from_file(cls, file_path):
"""Retrieves plugin version from RPM.
:param str file_path: path to rpm file
:returns: version of the plugin
"""
for line in utils.exec_cmd_iterator(
"rpm -qp --queryformat '%{{version}}' {0}".format(file_path)):
version = line
break
return version
@classmethod
def _remove_major_plugin_version(cls, name):
"""Removes the version from plugin name.
Here is an example: "name-1.0" -> "name"
:param str name: plugin name
:returns: the name withot version
"""
name_wo_version = name
if '-' in name_wo_version:
name_wo_version = '-'.join(name.split('-')[:-1])
return name_wo_version
class Plugins(base.BaseObject):
class_api_path = 'plugins/'
class_instance_path = 'plugins/{id}'
@classmethod
def register(cls, name, version, force=False):
"""Tries to find plugin on file system, creates
it in API service if it exists.
:param str name: plugin name
:param str version: plugin version
:param bool force: if True updates meta information
about the plugin even it does not
support updates
"""
metadata = None
for m in utils.glob_and_parse_yaml(METADATA_MASK):
if m.get('version') == version and \
m.get('name') == name:
metadata = m
break
if not metadata:
raise error.BadDataException(
'Plugin {0} with version {1} does '
'not exist, install it and try again'.format(
name, version))
return cls.update_or_create(metadata, force=force)
@classmethod
def sync(cls, plugin_ids=None):
"""Checks all of the plugins on file systems,
and makes sure that they have consistent information
in API service.
:params plugin_ids: list of ids for plugins which should be synced
:type plugin_ids: list
:returns: None
"""
post_data = None
if plugin_ids is not None:
post_data = {'ids': plugin_ids}
cls.connection.post_request(
api='plugins/sync/', data=post_data)
@classmethod
def unregister(cls, name, version):
"""Removes the plugin from API service
:param str name: plugin name
:param str version: plugin version
"""
plugin = cls.get_plugin(name, version)
return cls.connection.delete_request(
cls.class_instance_path.format(**plugin))
@classmethod
def install(cls, plugin_path, force=False):
"""Installs the package, and creates data in API service
:param str plugin_path: Name of plugin file
:param bool force: Updates existent plugin even if it is not updatable
:return: Plugins information
:rtype: dict
"""
if not utils.file_exists(plugin_path):
raise error.BadDataException(
"No such plugin file: {0}".format(plugin_path)
)
plugin = cls.make_obj_by_file(plugin_path)
name = plugin.name_from_file(plugin_path)
version = plugin.version_from_file(plugin_path)
plugin.install(plugin_path, force=force)
response = cls.register(name, version, force=force)
return response
@classmethod
def remove(cls, plugin_name, plugin_version):
"""Removes the package, and updates data in API service
:param str name: plugin name
:param str version: plugin version
"""
plugin = cls.make_obj_by_name(plugin_name, plugin_version)
cls.unregister(plugin_name, plugin_version)
return plugin.remove(plugin_name, plugin_version)
@classmethod
def update(cls, plugin_path):
"""Updates the package, and updates data in API service
:param str plugin_path: path to the plugin
"""
plugin = cls.make_obj_by_file(plugin_path)
name = plugin.name_from_file(plugin_path)
version = plugin.version_from_file(plugin_path)
plugin.update(plugin_path)
return cls.register(name, version)
@classmethod
def downgrade(cls, plugin_path):
"""Downgrades the package, and updates data in API service
:param str plugin_path: path to the plugin
"""
plugin = cls.make_obj_by_file(plugin_path)
name = plugin.name_from_file(plugin_path)
version = plugin.version_from_file(plugin_path)
plugin.downgrade(plugin_path)
return cls.register(name, version)
@classmethod
def make_obj_by_name(cls, name, version):
"""Finds appropriate plugin class version,
by plugin version and name.
:param str name:
:param str version:
:returns: plugin class
:raises: error.BadDataException unsupported package version
"""
plugin = cls.get_plugin(name, version)
package_version = plugin['package_version']
if StrictVersion('1.0.0') <= \
StrictVersion(package_version) < \
StrictVersion('2.0.0'):
return PluginV1
elif StrictVersion('2.0.0') <= StrictVersion(package_version):
return PluginV2
raise error.BadDataException(
'Plugin {0}=={1} has unsupported package version {2}'.format(
name, version, package_version))
@classmethod
def make_obj_by_file(cls, file_path):
"""Finds appropriate plugin class version,
by plugin file.
:param str file_path: plugin path
:returns: plugin class
:raises: error.BadDataException unsupported package version
"""
_, ext = os.path.splitext(file_path)
if ext == '.fp':
return PluginV1
elif ext == '.rpm':
return PluginV2
raise error.BadDataException(
'Plugin {0} has unsupported format {1}'.format(
file_path, ext))
@classmethod
def update_or_create(cls, metadata, force=False):
"""Try to update existent plugin or create new one.
:param dict metadata: plugin information
:param bool force: updates existent plugin even if
it is not updatable
"""
# Try to update plugin
plugin_for_update = cls.get_plugin_for_update(metadata)
if plugin_for_update:
url = cls.class_instance_path.format(id=plugin_for_update['id'])
resp = cls.connection.put_request(url, metadata)
return resp
# If plugin is not updatable it means that we should
# create new instance in Nailgun
resp_raw = cls.connection.post_request_raw(
cls.class_api_path, metadata)
resp = resp_raw.json()
if resp_raw.status_code == 409 and force:
# Replace plugin information
message = json.loads(resp['message'])
url = cls.class_instance_path.format(id=message['id'])
resp = cls.connection.put_request(url, metadata)
elif resp_raw.status_code == 409:
error.exit_with_error(
"Nothing to do: %(title)s, version "
"%(package_version)s, does not update "
"installed plugin." % metadata)
else:
resp_raw.raise_for_status()
return resp
@classmethod
def get_plugin_for_update(cls, metadata):
"""Retrieves plugins which can be updated
:param dict metadata: plugin metadata
:returns: dict with plugin which can be updated or None
"""
if not cls.is_updatable(metadata['package_version']):
return
plugins = [p for p in cls.get_all_data()
if (p['name'] == metadata['name'] and
cls.is_updatable(p['package_version']) and
utils.major_plugin_version(metadata['version']) ==
utils.major_plugin_version(p['version']))]
plugin = None
if plugins:
# List should contain only one plugin, but just
# in case we make sure that we get plugin with
# higher version
plugin = sorted(
plugins,
key=lambda p: StrictVersion(p['version']))[0]
return plugin
@classmethod
def is_updatable(cls, package_version):
"""Checks if plugin's package version supports updates.
:param str package_version: package version of the plugin
:returns: True if plugin can be updated
False if plugin cannot be updated
"""
return StrictVersion('2.0.0') <= StrictVersion(package_version)
@classmethod
def get_plugin(cls, name, version):
"""Returns plugin fetched by name and version.
:param str name: plugin name
:param str version: plugin version
:returns: dictionary with plugin data
:raises: error.BadDataException if no plugin was found
"""
plugins = [p for p in cls.get_all_data()
if (p['name'], p['version']) == (name, version)]
if not plugins:
raise error.BadDataException(
'Plugin "{name}" with version {version}, does '
'not exist'.format(name=name, version=version))
return plugins[0]