Environments operations handled in the fuel2

Requirement on package six added.
Detailed error description propagated for 404 errors.
Parse of comma separated client params implemented.
Delete and patch operation added to http client.

Change-Id: Id30358f9358047a0a6f6fd23341ab270edfa5874
This commit is contained in:
Alexander Kislitsky 2016-08-22 19:43:03 +03:00
parent d43e782421
commit ca3a669c2d
10 changed files with 361 additions and 14 deletions

View File

@ -10,3 +10,4 @@ alembic
cliff
requests
keystonemiddleware>=4.0.0,!=4.1.0,!=4.5.0
six>=1.9.0

View File

@ -52,10 +52,20 @@ tuning_box.cli =
get = tuning_box.cli.resources:Get
set = tuning_box.cli.resources:Set
override = tuning_box.cli.resources:Override
env_create = tuning_box.cli.environments:CreateEnvironment
env_list = tuning_box.cli.environments:ListEnvironments
env_show = tuning_box.cli.environments:ShowEnvironment
env_delete = tuning_box.cli.environments:DeleteEnvironment
env_update = tuning_box.cli.environments:UpdateEnvironment
fuelclient =
config_get = tuning_box.fuelclient:Get
config_set = tuning_box.fuelclient:Set
config_override = tuning_box.fuelclient:Override
config_env_create = tuning_box.fuelclient:CreateEnvironment
config_env_list = tuning_box.fuelclient:ListEnvironments
config_env_show = tuning_box.fuelclient:ShowEnvironment
config_env_delete = tuning_box.fuelclient:DeleteEnvironment
config_env_update = tuning_box.fuelclient:UpdateEnvironment
console_scripts =
tuningbox_db_upgrade = tuning_box.migration:upgrade
tuningbox_db_downgrade = tuning_box.migration:downgrade

View File

@ -14,6 +14,7 @@ import json
import yaml
from cliff import command
import six
def level_converter(value):
@ -53,9 +54,18 @@ def format_output(output, format_):
class BaseCommand(command.Command):
def get_client(self):
return self.app.client
def _parse_comma_separated(self, parsed_args, param_name, cast_to):
param = getattr(parsed_args, param_name)
if param is None or param == '[]':
return []
result = six.moves.map(six.text_type.strip,
six.text_type(param).split(','))
return list(six.moves.map(cast_to, result))
class FormattedCommand(BaseCommand):
format_choices = ('json', 'yaml', 'plain')
@ -63,7 +73,7 @@ class FormattedCommand(BaseCommand):
def get_parser(self, *args, **kwargs):
parser = super(FormattedCommand, self).get_parser(*args, **kwargs)
parser.add_argument(
'--format',
'-f', '--format',
choices=self.format_choices,
default='json',
help="Desired format for return value",

View File

@ -0,0 +1,109 @@
# 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 six
from tuning_box.cli import base
from tuning_box.cli.base import BaseCommand
class ListEnvironments(base.FormattedCommand, BaseCommand):
def take_action(self, parsed_args):
return self.get_client().get('/environments')
class CreateEnvironment(base.FormattedCommand, BaseCommand):
def get_parser(self, *args, **kwargs):
parser = super(CreateEnvironment, self).get_parser(
*args, **kwargs)
parser.add_argument(
'-c', '--components',
type=str,
help="Comma separated components IDs",
)
parser.add_argument(
'-l', '--levels',
type=str,
help="Comma separated levels names",
)
return parser
def take_action(self, parsed_args):
levels = self._parse_comma_separated(
parsed_args, 'levels', six.text_type)
components = self._parse_comma_separated(
parsed_args, 'components', int)
res = self.get_client().post(
'/environments',
{'hierarchy_levels': levels, 'components': components}
)
return res
class EnvironmentCommand(base.FormattedCommand, BaseCommand):
def get_parser(self, *args, **kwargs):
parser = super(EnvironmentCommand, self).get_parser(*args, **kwargs)
parser.add_argument('id', type=int, help='Id of the environment')
return parser
class ShowEnvironment(EnvironmentCommand):
def take_action(self, parsed_args):
return self.get_client().get('/environments/{0}'.format(
parsed_args.id))
class DeleteEnvironment(EnvironmentCommand):
def take_action(self, parsed_args):
return self.get_client().delete('/environments/{0}'.format(
parsed_args.id))
class UpdateEnvironment(EnvironmentCommand):
def get_parser(self, *args, **kwargs):
parser = super(UpdateEnvironment, self).get_parser(
*args, **kwargs)
parser.add_argument(
'-c', '--components',
dest='components',
type=str,
help="Comma separated components IDs. "
"Set parameter to [] if you want to pass empty list",
)
parser.add_argument(
'-l', '--levels',
type=str,
dest='levels',
help="Comma separated levels names "
"Set parameter to [] if you want to pass empty list",
)
return parser
def take_action(self, parsed_args):
data = {}
if parsed_args.levels is not None:
data['hierarchy_levels'] = self._parse_comma_separated(
parsed_args, 'levels', six.text_type)
if parsed_args.components is not None:
data['components'] = self._parse_comma_separated(
parsed_args, 'components', int)
return self.get_client().patch(
'/environments/{0}'.format(parsed_args.id),
data
)

View File

@ -32,7 +32,6 @@ class HTTPClient(object):
def request(self, method, url, **kwargs):
full_url = self.base_url + url
resp = self.session.request(method, full_url, **kwargs)
resp.raise_for_status()
if resp.headers.get('Content-Type') == 'application/json' and \
resp.content:
return resp.json()
@ -44,3 +43,12 @@ class HTTPClient(object):
def put(self, url, body):
return self.request('PUT', url, json=body)
def post(self, url, body):
return self.request('POST', url, json=body)
def patch(self, url, body):
return self.request('PATCH', url, json=body)
def delete(self, url):
return self.request('DELETE', url)

View File

@ -14,7 +14,6 @@ import functools
import json
import re
import flask
import flask_sqlalchemy
import sqlalchemy
import sqlalchemy.event
@ -56,7 +55,9 @@ class BaseQuery(flask_sqlalchemy.BaseQuery):
else:
result = self.filter_by(name=id_or_name).one_or_none()
if fail_on_none and result is None:
flask.abort(404)
raise errors.TuningboxNotFound(
"Object not found by name or id {0}".format(id_or_name)
)
return result
# one_or_none is not present in sqlalchemy < 1.0.9

View File

@ -17,7 +17,8 @@ from fuelclient import client as fc_client
from tuning_box import cli
from tuning_box.cli import base as cli_base
import tuning_box.cli.resources
from tuning_box.cli import environments
from tuning_box.cli import resources
from tuning_box import client as tb_client
@ -42,15 +43,35 @@ class FuelBaseCommand(cli_base.BaseCommand):
return FuelHTTPClient()
class Get(FuelBaseCommand, tuning_box.cli.resources.Get):
class Get(FuelBaseCommand, resources.Get):
pass
class Set(FuelBaseCommand, tuning_box.cli.resources.Set):
class Set(FuelBaseCommand, resources.Set):
pass
class Override(FuelBaseCommand, tuning_box.cli.resources.Override):
class Override(FuelBaseCommand, resources.Override):
pass
class CreateEnvironment(FuelBaseCommand, environments.CreateEnvironment):
pass
class ListEnvironments(FuelBaseCommand, environments.ListEnvironments):
pass
class ShowEnvironment(FuelBaseCommand, environments.ShowEnvironment):
pass
class DeleteEnvironment(FuelBaseCommand, environments.DeleteEnvironment):
pass
class UpdateEnvironment(FuelBaseCommand, environments.UpdateEnvironment):
pass

View File

@ -29,7 +29,7 @@ class EnvironmentsCollection(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(environment_fields)]
def get(self):
envs = db.Environment.query.all()
envs = db.Environment.query.order_by(db.Environment.id).all()
result = []
for env in envs:
hierarchy_levels = db.EnvironmentHierarchyLevel.\
@ -132,8 +132,8 @@ class Environment(flask_restful.Resource):
def put(self, environment_id):
return self.patch(environment_id)
def patch(self, env_id):
self._perform_update(env_id)
def patch(self, environment_id):
self._perform_update(environment_id)
return None, 204
@db.with_transaction

View File

@ -0,0 +1,186 @@
# 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 TestCreateEnvironment(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/environments',
'env create --levels lvl1 --components 1 --format json',
'{}')),
('yaml', ('/environments',
'env create --levels lvl1,lvl2 --components 1 '
'--format yaml',
'{}\n')),
('plain', ('/environments',
'env create --levels lvl1,lvl2 --components 1,2,3 '
'--format plain',
'{}')),
('plain', ('/environments',
'env create '
'--format plain',
'{}')),
('json', ('/environments',
'env create -l lvl1 -c 1 -f json',
'{}')),
('yaml', ('/environments',
'env create -l lvl1,lvl2 -c 1 -f yaml',
'{}\n')),
('plain', ('/environments',
'env create -l lvl1,lvl2 -c 1,2,3 -f plain',
'{}')),
('plain', ('/environments',
'env create -f plain',
'{}'))
]
]
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={},
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestListEnvironments(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/environments', 'env list', '{}')),
('yaml', ('/environments', 'env list --format yaml', '{}\n')),
('plain', ('/environments', 'env list --format plain', '{}'))
]
]
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 TestShowEnvironment(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/environments/9', 'env show 9 --format json', '{}')),
('yaml', ('/environments/9', 'env show 9 --format yaml', '{}\n')),
('plain', ('/environments/9', 'env show 9 --format plain', '{}'))
]
]
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 TestDeleteEnvironment(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/environments/9', 'env delete 9 --format json',
'{}')),
('yaml', ('/environments/9', 'env delete 9 --format yaml',
'{}\n')),
('plain', ('/environments/9', 'env delete 9 --format plain',
'{}'))
]
]
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'},
json={}
)
self.cli.run(self.args.split())
self.assertEqual(self.expected_result, self.cli.stdout.getvalue())
class TestUpdateEnvironment(testscenarios.WithScenarios, _BaseCLITest):
scenarios = [
(s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1])))
for s in [
('json', ('/environments/9',
'env update 9 -f json',
'{}')),
('json', ('/environments/9',
'env update 9 -l lvl1 -f json',
'{}')),
('yaml', ('/environments/9',
'env update 9 -l lvl1,lvl2 -f yaml',
'{}\n')),
('plain', ('/environments/9',
'env update 9 -l lvl1,lvl2 -c 1 -f plain',
'{}')),
('json', ('/environments/9',
'env update 9 -l lvl1,lvl2 -c 1,2 -f json',
'{}')),
('json', ('/environments/9',
'env update 9 -l [] -c 1,2 -f json',
'{}')),
('json', ('/environments/9',
'env update 9 -l [] -c [] -f json',
'{}')),
('json', ('/environments/9',
'env update 9 --levels [] --components [] --format json',
'{}')),
('json', ('/environments/9',
'env update 9 --levels a,b --components 1,2',
'{}')),
]
]
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

@ -14,6 +14,7 @@ import os
# oslo_db internals refuse to work properly if this is not set
# actual file name in that URL doesn't matter, it'll be generated by oslo.db
os.environ.setdefault("OS_TEST_DBAPI_ADMIN_CONNECTION", "sqlite:///testdb")
from alembic import command as alembic_command
@ -23,9 +24,9 @@ from oslo_db.sqlalchemy import test_base
from oslo_db.sqlalchemy import test_migrations
import sqlalchemy as sa
import testscenarios
from werkzeug import exceptions
from tuning_box import db
from tuning_box import errors
from tuning_box import migration
from tuning_box.tests import base
@ -79,14 +80,14 @@ class TestGetByIdOrName(_DBTestCase):
def test_by_id_fail(self):
self.assertRaises(
exceptions.NotFound,
errors.TuningboxNotFound,
db.Component.query.get_by_id_or_name,
self.component.id + 1,
)
def test_by_name_fail(self):
self.assertRaises(
exceptions.NotFound,
errors.TuningboxNotFound,
db.Component.query.get_by_id_or_name,
self.component.name + "_",
)