diff --git a/mistralclient/api/v2/client.py b/mistralclient/api/v2/client.py index 64d98b5d..004a24a1 100644 --- a/mistralclient/api/v2/client.py +++ b/mistralclient/api/v2/client.py @@ -1,4 +1,5 @@ # Copyright 2014 - Mirantis, Inc. +# Copyright 2015 - StackStorm, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,6 +18,7 @@ import six from mistralclient.api import httpclient from mistralclient.api.v2 import actions from mistralclient.api.v2 import cron_triggers +from mistralclient.api.v2 import environments from mistralclient.api.v2 import executions from mistralclient.api.v2 import tasks from mistralclient.api.v2 import workbooks @@ -53,6 +55,7 @@ class Client(object): self.actions = actions.ActionManager(self) self.workflows = workflows.WorkflowManager(self) self.cron_triggers = cron_triggers.CronTriggerManager(self) + self.environments = environments.EnvironmentManager(self) def authenticate(self, mistral_url=None, username=None, api_key=None, project_name=None, auth_url=None, project_id=None, diff --git a/mistralclient/api/v2/environments.py b/mistralclient/api/v2/environments.py new file mode 100644 index 00000000..03bfcfc1 --- /dev/null +++ b/mistralclient/api/v2/environments.py @@ -0,0 +1,79 @@ +# Copyright 2015 - StackStorm, 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 json + +from mistralclient.api import base + + +class Environment(base.Resource): + resource_name = 'Environment' + + def _set_attributes(self): + """Override loading of the "variables" attribute from text to dict.""" + for k, v in self._data.iteritems(): + if k == 'variables' and isinstance(v, basestring): + v = json.loads(v) + + try: + setattr(self, k, v) + except AttributeError: + # In this case we already defined the attribute on the class + pass + + +class EnvironmentManager(base.ResourceManager): + resource_class = Environment + + def create(self, **kwargs): + self._ensure_not_empty(name=kwargs.get('name', None), + variables=kwargs.get('variables', None)) + + # Convert dict to text for the variables attribute. + if isinstance(kwargs['variables'], dict): + kwargs['variables'] = json.dumps(kwargs['variables']) + + return self._create('/environments', kwargs) + + def update(self, **kwargs): + name = kwargs.get('name', None) + self._ensure_not_empty(name=name) + + attrs = kwargs.keys() + attrs.remove('name') + allowed = ['description', 'variables', 'scope'] + disallowed = list(set(attrs) - set(allowed)) + + if disallowed: + raise ValueError('The attributes %s cannot be updated.' % + disallowed) + + # Convert dict to text for the variables attribute. + if kwargs.get('variables') and isinstance(kwargs['variables'], dict): + kwargs['variables'] = json.dumps(kwargs['variables']) + + return self._update('/environments', kwargs) + + def list(self): + return self._list('/environments', response_key='environments') + + def get(self, name): + self._ensure_not_empty(name=name) + + return self._get('/environments/%s' % name) + + def delete(self, name): + self._ensure_not_empty(name=name) + + self._delete('/environments/%s' % name) diff --git a/mistralclient/commands/v2/environments.py b/mistralclient/commands/v2/environments.py new file mode 100644 index 00000000..ef181c6f --- /dev/null +++ b/mistralclient/commands/v2/environments.py @@ -0,0 +1,184 @@ +# Copyright 2015 - StackStorm, 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 argparse +import json +import logging + +from cliff import command +from cliff import show +import yaml + +from mistralclient.api.v2 import environments +from mistralclient.commands.v2 import base + +LOG = logging.getLogger(__name__) + + +def format_list(environment=None): + columns = ( + 'Name', + 'Description', + 'Scope', + 'Created at', + 'Updated at' + ) + + if environment: + data = ( + environment.name, + environment.description, + environment.scope, + environment.created_at, + ) + + if hasattr(environment, 'updated_at'): + data += (environment.updated_at or '',) + else: + data += (None,) + + else: + data = (tuple('' for _ in range(len(columns))),) + + return columns, data + + +def format(environment=None): + columns = ( + 'Name', + 'Description', + 'Variables', + 'Scope', + 'Created at', + 'Updated at' + ) + + if environment: + data = ( + environment.name, + environment.description, + json.dumps(environment.variables, indent=4), + environment.scope, + environment.created_at, + ) + + if hasattr(environment, 'updated_at'): + data += (environment.updated_at or '',) + else: + data += (None,) + + else: + data = (tuple('' for _ in range(len(columns))),) + + return columns, data + + +def load_file_content(f): + content = f.read() + + try: + data = yaml.safe_load(content) + except: + data = json.loads(content) + + return data + + +class List(base.MistralLister): + """List all environments.""" + + def _get_format_function(self): + return format_list + + def _get_resources(self, parsed_args): + return environments.EnvironmentManager(self.app.client).list() + + +class Get(show.ShowOne): + """Show specific environment.""" + + def get_parser(self, prog_name): + parser = super(Get, self).get_parser(prog_name) + + parser.add_argument( + 'name', + help='Environment name' + ) + + return parser + + def take_action(self, parsed_args): + environment = environments.EnvironmentManager(self.app.client).get( + parsed_args.name) + + return format(environment) + + +class Create(show.ShowOne): + """Create new environment.""" + + def get_parser(self, prog_name): + parser = super(Create, self).get_parser(prog_name) + + parser.add_argument( + 'file', + type=argparse.FileType('r'), + help='Environment configuration file in JSON or YAML' + ) + + return parser + + def take_action(self, parsed_args): + data = load_file_content(parsed_args.file) + manager = environments.EnvironmentManager(self.app.client) + environment = manager.create(**data) + + return format(environment) + + +class Delete(command.Command): + """Delete environment.""" + + def get_parser(self, prog_name): + parser = super(Delete, self).get_parser(prog_name) + + parser.add_argument('name', help='Environment name') + + return parser + + def take_action(self, parsed_args): + environments.EnvironmentManager(self.app.client).delete( + parsed_args.name) + + +class Update(show.ShowOne): + """Update environment.""" + + def get_parser(self, prog_name): + parser = super(Update, self).get_parser(prog_name) + + parser.add_argument( + 'file', + type=argparse.FileType('r'), + help='Environment configuration file in JSON or YAML' + ) + + return parser + + def take_action(self, parsed_args): + data = load_file_content(parsed_args.file) + manager = environments.EnvironmentManager(self.app.client) + environment = manager.update(**data) + + return format(environment) diff --git a/mistralclient/shell.py b/mistralclient/shell.py index 2d574afb..85de2c6a 100644 --- a/mistralclient/shell.py +++ b/mistralclient/shell.py @@ -1,4 +1,4 @@ -# Copyright 2014 StackStorm, Inc. +# Copyright 2015 StackStorm, Inc. # All Rights Reserved # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -27,6 +27,7 @@ import mistralclient.commands.v1.tasks import mistralclient.commands.v1.workbooks import mistralclient.commands.v2.actions import mistralclient.commands.v2.cron_triggers +import mistralclient.commands.v2.environments import mistralclient.commands.v2.executions import mistralclient.commands.v2.tasks import mistralclient.commands.v2.workbooks @@ -244,6 +245,14 @@ class MistralShell(app.App): 'workflow-update': mistralclient.commands.v2.workflows.Update, 'workflow-get-definition': mistralclient.commands.v2.workflows.GetDefinition, + 'environment-create': + mistralclient.commands.v2.environments.Create, + 'environment-delete': + mistralclient.commands.v2.environments.Delete, + 'environment-update': + mistralclient.commands.v2.environments.Update, + 'environment-list': mistralclient.commands.v2.environments.List, + 'environment-get': mistralclient.commands.v2.environments.Get, 'execution-create': mistralclient.commands.v2.executions.Create, 'execution-delete': mistralclient.commands.v2.executions.Delete, 'execution-update': mistralclient.commands.v2.executions.Update, diff --git a/mistralclient/tests/unit/v2/base.py b/mistralclient/tests/unit/v2/base.py index 1e1d9ccc..ed31df42 100644 --- a/mistralclient/tests/unit/v2/base.py +++ b/mistralclient/tests/unit/v2/base.py @@ -1,4 +1,5 @@ # Copyright 2014 - Mirantis, Inc. +# Copyright 2015 - StackStorm, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -26,3 +27,4 @@ class BaseClientV2Test(base.BaseClientTest): self.executions = self._client.executions self.tasks = self._client.tasks self.workflows = self._client.workflows + self.environments = self._client.environments diff --git a/mistralclient/tests/unit/v2/test_cli_environments.py b/mistralclient/tests/unit/v2/test_cli_environments.py new file mode 100644 index 00000000..0c7af656 --- /dev/null +++ b/mistralclient/tests/unit/v2/test_cli_environments.py @@ -0,0 +1,119 @@ +# Copyright 2015 - StackStorm, 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 copy +import datetime +import json +import os +import tempfile + +import mock +import yaml + +from mistralclient.api.v2 import environments +from mistralclient.commands.v2 import environments as environment_cmd +from mistralclient.tests.unit import base + + +ENVIRONMENT_DICT = { + 'name': 'env1', + 'description': 'Test Environment #1', + 'scope': 'private', + 'variables': { + 'server': 'localhost', + 'database': 'test', + 'timeout': 600, + 'verbose': True + }, + 'created_at': str(datetime.datetime.utcnow()), + 'updated_at': str(datetime.datetime.utcnow()) +} + +ENVIRONMENT = environments.Environment(mock, ENVIRONMENT_DICT) +EXPECTED_RESULT = (ENVIRONMENT_DICT['name'], + ENVIRONMENT_DICT['description'], + json.dumps(ENVIRONMENT_DICT['variables'], indent=4), + ENVIRONMENT_DICT['scope'], + ENVIRONMENT_DICT['created_at'], + ENVIRONMENT_DICT['updated_at']) + + +class TestCLIEnvironmentsV2(base.BaseCommandTest): + + @mock.patch('mistralclient.api.v2.environments.EnvironmentManager.create') + def _test_create(self, content, mock): + mock.return_value = ENVIRONMENT + + with tempfile.NamedTemporaryFile() as f: + f.write(content) + f.flush() + file_path = os.path.abspath(f.name) + result = self.call(environment_cmd.Create, app_args=[file_path]) + self.assertEqual(EXPECTED_RESULT, result[1]) + + def test_create_from_json(self): + self._test_create(json.dumps(ENVIRONMENT_DICT, indent=4)) + + def test_create_from_yaml(self): + yml = yaml.dump(ENVIRONMENT_DICT, default_flow_style=False) + self._test_create(yml) + + @mock.patch('mistralclient.api.v2.environments.EnvironmentManager.update') + def _test_update(self, content, mock): + mock.return_value = ENVIRONMENT + + with tempfile.NamedTemporaryFile() as f: + f.write(content) + f.flush() + file_path = os.path.abspath(f.name) + result = self.call(environment_cmd.Update, app_args=[file_path]) + self.assertEqual(EXPECTED_RESULT, result[1]) + + def test_update_from_json(self): + env = copy.deepcopy(ENVIRONMENT_DICT) + del env['created_at'] + del env['updated_at'] + self._test_update(json.dumps(env, indent=4)) + + def test_update_from_yaml(self): + env = copy.deepcopy(ENVIRONMENT_DICT) + del env['created_at'] + del env['updated_at'] + yml = yaml.dump(env, default_flow_style=False) + self._test_update(yml) + + @mock.patch('mistralclient.api.v2.environments.EnvironmentManager.list') + def test_list(self, mock): + mock.return_value = (ENVIRONMENT,) + expected = (ENVIRONMENT_DICT['name'], + ENVIRONMENT_DICT['description'], + ENVIRONMENT_DICT['scope'], + ENVIRONMENT_DICT['created_at'], + ENVIRONMENT_DICT['updated_at']) + + result = self.call(environment_cmd.List) + + self.assertListEqual([expected], result[1]) + + @mock.patch('mistralclient.api.v2.environments.EnvironmentManager.get') + def test_get(self, mock): + mock.return_value = ENVIRONMENT + + result = self.call(environment_cmd.Get, app_args=['name']) + + self.assertEqual(EXPECTED_RESULT, result[1]) + + @mock.patch('mistralclient.api.v2.environments.EnvironmentManager.delete') + def test_delete(self, mock): + self.assertIsNone(self.call(environment_cmd.Delete, app_args=['name'])) diff --git a/mistralclient/tests/unit/v2/test_environments.py b/mistralclient/tests/unit/v2/test_environments.py new file mode 100644 index 00000000..a86d5cba --- /dev/null +++ b/mistralclient/tests/unit/v2/test_environments.py @@ -0,0 +1,96 @@ +# Copyright 2015 - StackStorm, 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 copy +import json + +from mistralclient.api.v2 import environments +from mistralclient.tests.unit.v2 import base + + +ENVIRONMENT = { + 'name': 'env1', + 'description': 'Test Environment #1', + 'scope': 'private', + 'variables': { + 'server': 'localhost' + } +} + +URL_TEMPLATE = '/environments' +URL_TEMPLATE_NAME = '/environments/%s' + + +class TestEnvironmentsV2(base.BaseClientV2Test): + def test_create(self): + data = copy.deepcopy(ENVIRONMENT) + + mock = self.mock_http_post(content=data) + env = self.environments.create(**data) + + self.assertIsNotNone(env) + + expected_data = copy.deepcopy(data) + expected_data['variables'] = json.dumps(expected_data['variables']) + + mock.assert_called_once_with(URL_TEMPLATE, json.dumps(expected_data)) + + def test_update(self): + data = copy.deepcopy(ENVIRONMENT) + + mock = self.mock_http_put(content=data) + env = self.environments.update(**data) + + self.assertIsNotNone(env) + + expected_data = copy.deepcopy(data) + expected_data['variables'] = json.dumps(expected_data['variables']) + + mock.assert_called_once_with(URL_TEMPLATE, json.dumps(expected_data)) + + def test_list(self): + mock = self.mock_http_get(content={'environments': [ENVIRONMENT]}) + + environment_list = self.environments.list() + + self.assertEqual(1, len(environment_list)) + + env = environment_list[0] + + self.assertDictEqual( + environments.Environment(self.environments, ENVIRONMENT).__dict__, + env.__dict__ + ) + + mock.assert_called_once_with(URL_TEMPLATE) + + def test_get(self): + mock = self.mock_http_get(content=ENVIRONMENT) + + env = self.environments.get('env') + + self.assertIsNotNone(env) + self.assertDictEqual( + environments.Environment(self.environments, ENVIRONMENT).__dict__, + env.__dict__ + ) + + mock.assert_called_once_with(URL_TEMPLATE_NAME % 'env') + + def test_delete(self): + mock = self.mock_http_delete(status_code=204) + + self.environments.delete('env') + + mock.assert_called_once_with(URL_TEMPLATE_NAME % 'env') diff --git a/requirements.txt b/requirements.txt index ce0a6e27..d3565ba5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -pbr>=0.6,!=0.7,<1.0 -requests>=1.2.1,!=2.4.0 -python-keystoneclient>=0.10.0 cliff>=1.7.0 # Apache-2.0 +pbr>=0.6,!=0.7,<1.0 +python-keystoneclient>=0.10.0 +PyYAML>=3.1.0 +requests>=1.2.1,!=2.4.0