Components operations handled in the fuel2

Cascade deletion of resource definitons on the component deletion
fixed. Cascade only on the DB level is not enougth for deletion
referenced objects.
Resources definitions creation is not supported yet in the
component creation call. Resource definition would be able to
add to the component in the create resource definition call.
Update of resource definitions list is supported in the component
creation call.
Standard cliff formatters are used for fuel2 components operations.
Python-fuelclient added to test requirements.

Change-Id: If572f7437f48bdde65de114f6f70af3c071c1d0e
This commit is contained in:
Alexander Kislitsky 2016-08-23 19:08:39 +03:00
parent ca3a669c2d
commit 76867c6aa4
10 changed files with 353 additions and 13 deletions

View File

@ -57,6 +57,11 @@ tuning_box.cli =
env_show = tuning_box.cli.environments:ShowEnvironment
env_delete = tuning_box.cli.environments:DeleteEnvironment
env_update = tuning_box.cli.environments:UpdateEnvironment
comp_create = tuning_box.cli.components:CreateComponent
comp_list = tuning_box.cli.components:ListComponents
comp_show = tuning_box.cli.components:ShowComponent
comp_delete = tuning_box.cli.components:DeleteComponent
comp_update = tuning_box.cli.components:UpdateComponent
fuelclient =
config_get = tuning_box.fuelclient:Get
config_set = tuning_box.fuelclient:Set
@ -66,6 +71,11 @@ fuelclient =
config_env_show = tuning_box.fuelclient:ShowEnvironment
config_env_delete = tuning_box.fuelclient:DeleteEnvironment
config_env_update = tuning_box.fuelclient:UpdateEnvironment
config_comp_create = tuning_box.fuelclient:CreateComponent
config_comp_list = tuning_box.fuelclient:ListComponents
config_comp_show = tuning_box.fuelclient:ShowComponent
config_comp_delete = tuning_box.fuelclient:DeleteComponent
config_comp_update = tuning_box.fuelclient:UpdateComponent
console_scripts =
tuningbox_db_upgrade = tuning_box.migration:upgrade
tuningbox_db_downgrade = tuning_box.migration:downgrade

View File

@ -15,3 +15,4 @@ testrepository>=0.0.18
testscenarios>=0.4
testtools>=1.4.0
requests-mock
python-fuelclient

View File

@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import abc
import json
import yaml
@ -67,6 +68,44 @@ class BaseCommand(command.Command):
return list(six.moves.map(cast_to, result))
class BaseOneCommand(BaseCommand):
@abc.abstractproperty
def base_url(self):
"""Base url for request operations"""
@abc.abstractproperty
def entity_name(self):
"""Name of the TuningBox entity"""
def get_parser(self, *args, **kwargs):
parser = super(BaseOneCommand, self).get_parser(*args, **kwargs)
parser.add_argument(
'id',
type=int,
help='Id of the {0} to delete.'.format(self.entity_name))
return parser
def get_url(self, parsed_args):
return '{0}/{1}'.format(self.base_url, parsed_args.id)
def get_deletion_message(self, parsed_args):
return '{0} with id {1} was deleted'.format(
self.entity_name.capitalize(), parsed_args.id)
def get_update_message(self, parsed_args):
return '{0} with id {1} was updated'.format(
self.entity_name.capitalize(), parsed_args.id)
class BaseDeleteCommand(BaseOneCommand):
"""Deletes entity with the specified id."""
def take_action(self, parsed_args):
self.get_client().delete(self.get_url(parsed_args))
return self.get_deletion_message(parsed_args)
class FormattedCommand(BaseCommand):
format_choices = ('json', 'yaml', 'plain')

View File

@ -0,0 +1,103 @@
# 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 cliff import lister
from cliff import show
from fuelclient.cli import error as fc_error
from fuelclient.common import data_utils
from tuning_box.cli import base
class ComponentsCommand(base.BaseCommand):
entity_name = 'component'
base_url = '/components'
columns = ('id', 'name', 'resource_definitions')
class ListComponents(ComponentsCommand, lister.Lister):
def take_action(self, parsed_args):
result = self.get_client().get(self.base_url)
try:
data = data_utils.get_display_data_multi(self.columns, result)
return self.columns, data
except fc_error.BadDataException:
return zip(*result.items())
class CreateComponent(ComponentsCommand, show.ShowOne):
def get_parser(self, *args, **kwargs):
parser = super(CreateComponent, self).get_parser(
*args, **kwargs)
parser.add_argument(
'-n', '--name',
type=str,
help="Component name"
)
return parser
def take_action(self, parsed_args):
result = self.get_client().post(
'/components', {'name': parsed_args.name,
'resource_definitions': []})
return zip(*result.items())
class ShowComponent(ComponentsCommand, base.BaseOneCommand, show.ShowOne):
def take_action(self, parsed_args):
result = self.get_client().get(self.get_url(parsed_args))
try:
data = data_utils.get_display_data_single(self.columns, result)
return self.columns, data
except fc_error.BadDataException:
return zip(*result.items())
class DeleteComponent(ComponentsCommand, base.BaseDeleteCommand):
pass
class UpdateComponent(ComponentsCommand, base.BaseOneCommand):
def get_parser(self, *args, **kwargs):
parser = super(UpdateComponent, self).get_parser(
*args, **kwargs)
parser.add_argument(
'-n', '--name',
type=str,
help="Component name"
)
parser.add_argument(
'-r', '--resource-definitions',
dest='resources',
type=str,
help="Comma separated resource definitions IDs. "
"Set parameter to [] if you want to pass empty list",
)
return parser
def take_action(self, parsed_args):
data = {}
if parsed_args.name is not None:
data['name'] = parsed_args.name
if parsed_args.resources is not None:
data['resource_definitions'] = []
res_def_ids = self._parse_comma_separated(
parsed_args, 'resources', int)
for res_def_id in res_def_ids:
data['resource_definitions'].append({'id': res_def_id})
self.get_client().patch(self.get_url(parsed_args), data)
return self.get_update_message(parsed_args)

View File

@ -128,7 +128,12 @@ class Component(ModelMixin, db.Model):
class ResourceDefinition(ModelMixin, db.Model):
name = db.Column(db.String(128))
component_id = fk(Component, ondelete='CASCADE')
component = db.relationship(Component, backref='resource_definitions')
component = db.relationship(
Component,
backref=sqlalchemy.orm.backref('resource_definitions',
cascade='all, delete-orphan')
)
content = db.Column(Json)
__repr_attrs__ = ('id', 'name', 'component', 'content')
@ -259,7 +264,7 @@ def get_or_404(cls, ident):
result = cls.query.get(ident)
if result is None:
raise errors.TuningboxNotFound(
"{0} not found by {1}".format(cls.__name__, ident)
"{0} not found by id {1}".format(cls.__name__, ident)
)
return result

View File

@ -17,6 +17,7 @@ from fuelclient import client as fc_client
from tuning_box import cli
from tuning_box.cli import base as cli_base
from tuning_box.cli import components
from tuning_box.cli import environments
from tuning_box.cli import resources
from tuning_box import client as tb_client
@ -75,6 +76,26 @@ class UpdateEnvironment(FuelBaseCommand, environments.UpdateEnvironment):
pass
class CreateComponent(FuelBaseCommand, components.CreateComponent):
pass
class ListComponents(FuelBaseCommand, components.ListComponents):
pass
class ShowComponent(FuelBaseCommand, components.ShowComponent):
pass
class DeleteComponent(FuelBaseCommand, components.DeleteComponent):
pass
class UpdateComponent(FuelBaseCommand, components.UpdateComponent):
pass
class Config(command.Command):
def get_parser(self, *args, **kwargs):
parser = super(Config, self).get_parser(*args, **kwargs)

View File

@ -32,13 +32,13 @@ class ComponentsCollection(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(component_fields)]
def get(self):
return db.Component.query.all()
return db.Component.query.order_by(db.Component.id).all()
@db.with_transaction
def post(self):
component = db.Component(name=flask.request.json['name'])
component.resource_definitions = []
for res_def_data in flask.request.json.get('resource_definitions'):
for res_def_data in flask.request.json.get('resource_definitions', []):
res_def = db.ResourceDefinition(
name=res_def_data['name'], content=res_def_data.get('content'))
component.resource_definitions.append(res_def)
@ -50,11 +50,11 @@ class Component(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(component_fields)]
def get(self, component_id):
return db.Component.query.get_or_404(component_id)
return db.get_or_404(db.Component, component_id)
@db.with_transaction
def _perform_update(self, component_id):
component = db.Component.query.get_or_404(component_id)
component = db.get_or_404(db.Component, component_id)
update_by = flask.request.json
component.name = update_by.get('name', component.name)
res_definitions = update_by.get('resource_definitions')
@ -72,6 +72,6 @@ class Component(flask_restful.Resource):
@db.with_transaction
def delete(self, component_id):
component = db.Component.query.get_or_404(component_id)
component = db.get_or_404(db.Component, component_id)
db.db.session.delete(component)
return None, 204

View File

@ -83,10 +83,11 @@ class SafeTuningBoxApp(cli.TuningBoxApp):
def run(self, argv):
try:
exit_code = super(SafeTuningBoxApp, self).run(argv)
super(SafeTuningBoxApp, self).run(argv)
except SystemExit as e:
# We should check exit code only if system exit was called.
exit_code = e.code
assert exit_code == 0
assert exit_code == 0
class _BaseCLITest(base.TestCase):

View File

@ -0,0 +1,144 @@
# 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 testscenarios
from tuning_box.tests.cli import _BaseCLITest
class TestCreateComponent(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/components',
'comp create --name comp_name --format json',
'{\n "a": "b"\n}')),
('yaml', ('/components',
'comp create -n comp_name -f yaml',
'a: b\n')),
]
]
mock_url = None
args = None
expected_result = None
def test_post(self):
self.req_mock.post(
self.BASE_URL + self.mock_url,
headers={'Content-Type': 'application/json'},
json={'a': 'b'},
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestListComponents(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/components', 'comp list -f json', '[]')),
('yaml', ('/components', 'comp list --format yaml', '[]\n')),
]
]
mock_url = None
args = None
expected_result = None
def test_get(self):
self.req_mock.get(
self.BASE_URL + self.mock_url,
headers={'Content-Type': 'application/json'},
json=[],
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestShowComponent(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('yaml', ('/components/9', 'comp show 9 -f yaml',
'id: 1\nname: n\nresource_definitions: []\n')),
]
]
mock_url = None
args = None
expected_result = None
def test_get(self):
self.req_mock.get(
self.BASE_URL + self.mock_url,
headers={'Content-Type': 'application/json'},
json={'id': 1, 'name': 'n', 'resource_definitions': []},
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestDeleteComponent(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('', ('/components/9', 'comp delete 9', '')),
]
]
mock_url = None
args = None
expected_result = None
def test_delete(self):
self.req_mock.delete(
self.BASE_URL + self.mock_url,
headers={'Content-Type': 'application/json'}
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestUpdateComponent(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('no_data', ('/components/9', 'comp update 9', '')),
('s_name', ('/components/9',
'comp update 9 -n comp_name', '')),
('l_name', ('/components/9',
'comp update 9 --name comp_name', '')),
('s_r_defs', ('/components/9',
'comp update 9 -r 1,2 ', '')),
('l_r_ders', ('/components/9',
'comp update 9 --resource-definitions 1,2', '')),
('empty_s_r_defs', ('/components/9',
'comp update 9 -r [] -n comp_name', '')),
('empty_l_r_defs', ('/components/9',
'comp update 9 --resource-definitions []',
''))
]
]
mock_url = None
args = None
expected_result = None
def test_update(self):
self.req_mock.patch(
self.BASE_URL + self.mock_url,
headers={'Content-Type': 'application/json'},
json={}
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())

View File

@ -115,6 +115,10 @@ class TestComponents(BaseTest):
self.assertEqual(res.data, b'')
self._assert_not_in_db(db.Component, 7)
with self.app.app_context():
actual_res_defs = db.ResourceDefinition.query.all()
self.assertEqual([], actual_res_defs)
def test_delete_component_404(self):
res = self.client.delete('/components/7')
self.assertEqual(res.status_code, 404)
@ -145,17 +149,29 @@ class TestComponents(BaseTest):
self.assertEqual([], actual_component['resource_definitions'])
# Restoring resource_definitions and name
res_def = {
'name': 'resdef1',
'component_id': 7,
'content': {'key': 'nsname.key'}
}
res = self.client.post(
'/resource_definitions',
data=res_def
)
self.assertEqual(201, res.status_code)
res = self.client.put(
component_url,
data={'name': initial_data['name'],
'resource_definitions': initial_data['resource_definitions']}
data={'name': initial_data['name']}
)
self.assertEqual(204, res.status_code)
actual_component = self.client.get(component_url).json
self.assertEqual(initial_data['name'],
actual_component['name'])
self.assertItemsEqual(initial_data['resource_definitions'],
actual_component['resource_definitions'])
self.assertItemsEqual(
(d['name'] for d in initial_data['resource_definitions']),
(d['name'] for d in actual_component['resource_definitions'])
)
def test_put_component_resource_not_found(self):
self._fixture()