527 lines
16 KiB
Python
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]
|