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:
parent
ca3a669c2d
commit
76867c6aa4
10
setup.cfg
10
setup.cfg
|
@ -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
|
||||
|
|
|
@ -15,3 +15,4 @@ testrepository>=0.0.18
|
|||
testscenarios>=0.4
|
||||
testtools>=1.4.0
|
||||
requests-mock
|
||||
python-fuelclient
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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())
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue