Add cli commands to interact with plugins

- List all plugins installed
fuel plugins
fuel plugins --list

- Install plugin
fuel plugins --install /tmp/sample.pb
1. Extracts metadata from plugin tar
2. If plugin successfully registered in nailgun
3. Untar all the files in /var/www/nailgun/plugins

- If you want to update plugin when it is already installed
  add --force flag
fuel plugins --install /tmp/sample --force
The procedure is the same as for simple install, but
if plugin already registered - it will try to update
(send PUT request)

- Refactored tests to allow writing unit tests, with mock
  and all that goodness

related to blueprint cinder-neutron-plugins-in-fuel

Change-Id: I642a300abcb9c50ef5039a343064f2ac78e39cd8
This commit is contained in:
Dima Shulyak 2014-10-17 14:19:50 +03:00
parent 243cea0b60
commit 316b8854af
12 changed files with 255 additions and 25 deletions

View File

@ -31,6 +31,7 @@ from fuelclient.cli.actions.settings import SettingsAction
from fuelclient.cli.actions.snapshot import SnapshotAction
from fuelclient.cli.actions.task import TaskAction
from fuelclient.cli.actions.user import UserAction
from fuelclient.cli.actions.plugins import PluginAction
actions_tuple = (
ReleaseAction,
@ -47,7 +48,8 @@ actions_tuple = (
TaskAction,
SnapshotAction,
HealthCheckAction,
UserAction
UserAction,
PluginAction
)
actions = dict(

View File

@ -0,0 +1,67 @@
# 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.
from fuelclient.cli.actions.base import Action
import fuelclient.cli.arguments as Args
from fuelclient.cli.formatting import format_table
from fuelclient.objects.plugins import Plugins
class PluginAction(Action):
"""List and modify currently available releases
"""
action_name = "plugins"
acceptable_keys = (
"id",
"name",
"version",
"package_version",
)
def __init__(self):
super(PluginAction, self).__init__()
self.args = [
Args.get_list_arg("List all available plugins."),
Args.get_plugin_install_arg("Install action"),
Args.get_force_arg("Update action"),
]
self.flag_func_map = (
("install", self.install),
(None, self.list),
)
def list(self, params):
"""Print all available plugins:
fuel plugins
fuel plugins --list
"""
plugins = Plugins.get_all_data()
self.serializer.print_to_output(
plugins,
format_table(
plugins,
acceptable_keys=self.acceptable_keys
)
)
def install(self, params):
"""Enable plugin for environment
fuel plugins --install /tmp/plugin_sample.fb
"""
results = Plugins.install_plugin(params.install, params.force)
self.serializer.print_to_output(
results,
"Plugin {0} was successfully installed.".format(
params.install))

View File

@ -367,3 +367,11 @@ def get_task_arg(help_msg):
"help": help_msg
}
return get_arg("task", **default_kwargs)
def get_plugin_install_arg(help_msg):
return get_str_arg(
"install",
flags=("--install",),
help=help_msg
)

View File

@ -35,6 +35,14 @@ class FuelClientException(Exception):
self.message = args[0]
class BadDataException(FuelClientException):
"""Should be raised when user provides corrupted data."""
class WrongEnvironmentError(FuelClientException):
"""Raised when particular action is not supported on environment."""
class ServerDataException(FuelClientException):
"""ServerDataException - must be raised when
data returned from server cannot be processed by Fuel-Client methods.

View File

@ -29,8 +29,8 @@ class Parser:
and based on available actions, serializers and additional flags
populates it.
"""
def __init__(self):
self.args = sys.argv
def __init__(self, argv):
self.args = argv
self.parser = argparse.ArgumentParser(
usage="""
Configuration for client you can find in
@ -172,6 +172,6 @@ class Parser:
@exceptions_decorator
def main():
parser = Parser()
def main(args=sys.argv):
parser = Parser(args)
parser.parse()

View File

@ -160,10 +160,7 @@ class Client(object):
return resp.json()
@exceptions_decorator
def post_request(self, api, data, ostf=False):
"""Make POST request to specific API with some data
"""
def post_request_raw(self, api, data, ostf=False):
url = (self.ostf_root if ostf else self.api_root) + api
data_json = json.dumps(data)
self.print_debug(
@ -173,7 +170,13 @@ class Client(object):
headers = {'content-type': 'application/json',
'x-auth-token': self.auth_token}
resp = requests.post(url, data=data_json, headers=headers)
return requests.post(url, data=data_json, headers=headers)
@exceptions_decorator
def post_request(self, api, data, ostf=False):
"""Make POST request to specific API with some data
"""
resp = self.post_request_raw(api, data, ostf=ostf)
resp.raise_for_status()
return resp.json()

View File

@ -0,0 +1,70 @@
# 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 os
import tarfile
import yaml
from fuelclient.cli import error
from fuelclient.objects import base
EXTRACT_PATH = "/var/www/nailgun/plugins/"
VERSIONS_PATH = '/etc/fuel/version.yaml'
class Plugins(base.BaseObject):
class_api_path = "plugins/"
class_instance_path = "plugins/{id}"
metadata_config = 'metadata.yaml'
@classmethod
def validate_environment(cls):
return os.path.exists(VERSIONS_PATH)
@classmethod
def get_metadata(cls, plugin_tar):
for member_name in plugin_tar.getnames():
if cls.metadata_config in member_name:
return yaml.load(plugin_tar.extractfile(member_name).read())
raise error.BadDataException("Tarfile {0} doesn't have {1}".format(
plugin_tar.name, cls.metadata_config))
@classmethod
def add_plugin(cls, plugin_meta, plugin_tar):
return plugin_tar.extractall(EXTRACT_PATH)
@classmethod
def install_plugin(cls, plugin_path, force=False):
if not cls.validate_environment():
raise error.WrongEnvironmentError(
'Plugin can be installed only from master node.')
plugin_tar = tarfile.open(plugin_path, 'r')
try:
metadata = cls.get_metadata(plugin_tar)
resp = cls.connection.post_request_raw(
cls.class_api_path, metadata)
if resp.status_code == 409 and force:
url = cls.class_instance_path.format(id=resp.json()['id'])
resp = cls.connection.put_request(
url, metadata)
else:
resp.raise_for_status()
cls.add_plugin(metadata, plugin_tar)
finally:
plugin_tar.close()
return resp

View File

@ -25,6 +25,9 @@ import subprocess
import sys
import tempfile
from fuelclient.cli.parser import main
logging.basicConfig(stream=sys.stderr)
log = logging.getLogger("CliTest.ExecutionLog")
log.setLevel(logging.DEBUG)
@ -45,7 +48,14 @@ class CliExectutionResult:
return self.return_code == 0
class BaseTestCase(TestCase):
class UnitTestCase(TestCase):
"""Base test class which does not require nailgun server to run."""
def execute(self, command):
return main(command)
class BaseTestCase(UnitTestCase):
root_path = os.path.abspath(
os.path.join(
os.curdir,
@ -68,6 +78,18 @@ class BaseTestCase(TestCase):
def tearDown(self):
shutil.rmtree(self.temp_directory)
@staticmethod
def run_command(*args):
handle = subprocess.Popen(
[" ".join(args)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True
)
log.debug("Running " + " ".join(args))
out, err = handle.communicate()
log.debug("Finished command with {0} - {1}".format(out, err))
def upload_command(self, cmd):
return "{0} --upload --dir {1}".format(cmd, self.temp_directory)
@ -88,18 +110,6 @@ class BaseTestCase(TestCase):
)
))
@staticmethod
def run_command(*args):
handle = subprocess.Popen(
[" ".join(args)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True
)
log.debug("Running " + " ".join(args))
out, err = handle.communicate()
log.debug("Finished command with {0} - {1}".format(out, err))
def run_cli_command(self, command_line, check_errors=False):
modified_env = os.environ.copy()
command_args = [" ".join((self.fuel_path, command_line))]

View File

@ -17,7 +17,7 @@
import os
from tests.base import BaseTestCase
from fuelclient.tests.base import BaseTestCase
class TestHandlers(BaseTestCase):

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
#
# 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.
from mock import Mock
from mock import patch
from fuelclient.tests import base
DATA = """
name: sample
version: 0.1.0
"""
@patch('fuelclient.client.requests')
class TestPluginsActions(base.UnitTestCase):
def test_001_plugins_action(self, mrequests):
self.execute(['fuel', 'plugins'])
plugins_call = mrequests.get.call_args_list[-1]
url = plugins_call[0][0]
self.assertIn('api/v1/plugins', url)
@patch('fuelclient.objects.plugins.tarfile')
@patch('fuelclient.objects.plugins.os')
def test_install_plugin(self, mos, mtar, mrequests):
mos.path.exists.return_value = True
mtar.open().getnames.return_value = ['metadata.yaml']
mtar.open().extractfile().read.return_value = DATA
response_mock = Mock(status_code=201)
mrequests.post.return_value = response_mock
self.execute(
['fuel', 'plugins', '--install', '/tmp/sample.fp'])
self.assertEqual(mrequests.post.call_count, 1)
self.assertEqual(mrequests.put.call_count, 0)
@patch('fuelclient.objects.plugins.tarfile')
@patch('fuelclient.objects.plugins.os')
def test_install_plugin_with_force(self, mos, mtar, mrequests):
mos.path.exists.return_value = True
mtar.open().getnames.return_value = ['metadata.yaml']
mtar.open().extractfile().read.return_value = DATA
response_mock = Mock(status_code=409)
response_mock.json.return_value = {'id': '12'}
mrequests.post.return_value = response_mock
self.execute(
['fuel', 'plugins', '--install', '/tmp/sample.fp', '--force'])
self.assertEqual(mrequests.post.call_count, 1)
self.assertEqual(mrequests.put.call_count, 1)

View File

@ -339,7 +339,7 @@ function run_webui_tests {
# We are going to pass nailgun url to test runner.
function run_cli_tests {
local SERVER_PORT=$FUELCLIENT_SERVER_PORT
local TESTS=$ROOT/fuelclient/tests
local TESTS=$ROOT/fuelclient/fuelclient/tests
local artifacts=$ARTIFACTS/cli
local config=$artifacts/test.yaml
prepare_artifacts $artifacts $config