diff --git a/mistralclient/api/v2/actions.py b/mistralclient/api/v2/actions.py index ea5e12ef..18c866ca 100644 --- a/mistralclient/api/v2/actions.py +++ b/mistralclient/api/v2/actions.py @@ -15,7 +15,7 @@ import six from mistralclient.api import base - +from mistralclient import utils urlparse = six.moves.urllib.parse @@ -30,6 +30,10 @@ class ActionManager(base.ResourceManager): def create(self, definition, scope='private'): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.post( '/actions?scope=%s' % scope, definition, @@ -45,6 +49,10 @@ class ActionManager(base.ResourceManager): def update(self, definition, scope='private'): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.put( '/actions?scope=%s' % scope, definition, diff --git a/mistralclient/api/v2/environments.py b/mistralclient/api/v2/environments.py index 062ddb51..da53d4e7 100644 --- a/mistralclient/api/v2/environments.py +++ b/mistralclient/api/v2/environments.py @@ -17,6 +17,7 @@ import json import six from mistralclient.api import base +from mistralclient import utils class Environment(base.Resource): @@ -39,6 +40,12 @@ class EnvironmentManager(base.ResourceManager): resource_class = Environment def create(self, **kwargs): + # Check to see if the file name or URI is being passed in. If so, + # read it's contents first. + if 'file' in kwargs: + file = kwargs['file'] + kwargs = utils.load_content(utils.get_contents_if_file(file)) + self._ensure_not_empty(name=kwargs.get('name', None), variables=kwargs.get('variables', None)) @@ -49,6 +56,12 @@ class EnvironmentManager(base.ResourceManager): return self._create('/environments', kwargs) def update(self, **kwargs): + # Check to see if the file name or URI is being passed in. If so, + # read it's contents first. + if 'file' in kwargs: + file = kwargs['file'] + kwargs = utils.load_content(utils.get_contents_if_file(file)) + name = kwargs.get('name', None) self._ensure_not_empty(name=name) diff --git a/mistralclient/api/v2/workbooks.py b/mistralclient/api/v2/workbooks.py index f0b7b471..990533f4 100644 --- a/mistralclient/api/v2/workbooks.py +++ b/mistralclient/api/v2/workbooks.py @@ -14,6 +14,7 @@ # limitations under the License. from mistralclient.api import base +from mistralclient import utils class Workbook(base.Resource): @@ -26,6 +27,10 @@ class WorkbookManager(base.ResourceManager): def create(self, definition): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.post( '/workbooks', definition, @@ -40,6 +45,10 @@ class WorkbookManager(base.ResourceManager): def update(self, definition): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.put( '/workbooks', definition, @@ -67,6 +76,10 @@ class WorkbookManager(base.ResourceManager): def validate(self, definition): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.post( '/workbooks/validate', definition, diff --git a/mistralclient/api/v2/workflows.py b/mistralclient/api/v2/workflows.py index 6cef0693..3bd48754 100644 --- a/mistralclient/api/v2/workflows.py +++ b/mistralclient/api/v2/workflows.py @@ -16,6 +16,7 @@ import six from mistralclient.api import base +from mistralclient import utils urlparse = six.moves.urllib.parse @@ -31,6 +32,10 @@ class WorkflowManager(base.ResourceManager): def create(self, definition, scope='private'): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.post( '/workflows?scope=%s' % scope, definition, @@ -48,6 +53,10 @@ class WorkflowManager(base.ResourceManager): url_pre = ('/workflows/%s' % id) if id else '/workflows' + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.put( '%s?scope=%s' % (url_pre, scope), definition, @@ -99,6 +108,10 @@ class WorkflowManager(base.ResourceManager): def validate(self, definition): self._ensure_not_empty(definition=definition) + # If the specified definition is actually a file, read in the + # definition file + definition = utils.get_contents_if_file(definition) + resp = self.client.http_client.post( '/workflows/validate', definition, diff --git a/mistralclient/tests/unit/resources/action_v2.yaml b/mistralclient/tests/unit/resources/action_v2.yaml new file mode 100644 index 00000000..32360f25 --- /dev/null +++ b/mistralclient/tests/unit/resources/action_v2.yaml @@ -0,0 +1,10 @@ + +--- +version: 2.0 + +my_action: + base: std.echo + base-input: + output: 'Bye!' + output: + info: <% $.output %> diff --git a/mistralclient/tests/unit/resources/env_v2.json b/mistralclient/tests/unit/resources/env_v2.json new file mode 100644 index 00000000..e4945430 --- /dev/null +++ b/mistralclient/tests/unit/resources/env_v2.json @@ -0,0 +1,8 @@ +{ + "name": "env1", + "description": "Test Environment #1", + "scope": "private", + "variables": { + "server": "localhost" + } +} \ No newline at end of file diff --git a/mistralclient/tests/unit/resources/env_v2.yaml b/mistralclient/tests/unit/resources/env_v2.yaml new file mode 100644 index 00000000..f13454eb --- /dev/null +++ b/mistralclient/tests/unit/resources/env_v2.yaml @@ -0,0 +1,7 @@ +--- + +"name": "env1" +"description": "Test Environment #1" +"scope": "private" +"variables": + "server": "localhost" \ No newline at end of file diff --git a/mistralclient/tests/unit/resources/wb_v2.yaml b/mistralclient/tests/unit/resources/wb_v2.yaml new file mode 100644 index 00000000..344f4cfe --- /dev/null +++ b/mistralclient/tests/unit/resources/wb_v2.yaml @@ -0,0 +1,21 @@ + +--- +version: 2.0 + +name: wb + +workflows: + wf1: + type: direct + input: + - param1 + - param2 + + tasks: + task1: + action: std.http url="localhost:8989" + on-success: + - test_subsequent + + test_subsequent: + action: std.http url="http://some_url" server_id=1 diff --git a/mistralclient/tests/unit/resources/wf_v2.yaml b/mistralclient/tests/unit/resources/wf_v2.yaml new file mode 100644 index 00000000..c9e30e93 --- /dev/null +++ b/mistralclient/tests/unit/resources/wf_v2.yaml @@ -0,0 +1,10 @@ + +--- +version: 2.0 + +my_wf: + type: direct + + tasks: + task1: + action: std.echo output="hello, world" diff --git a/mistralclient/tests/unit/v2/test_actions.py b/mistralclient/tests/unit/v2/test_actions.py index 5a538b38..9416fe3c 100644 --- a/mistralclient/tests/unit/v2/test_actions.py +++ b/mistralclient/tests/unit/v2/test_actions.py @@ -11,6 +11,11 @@ # 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 pkg_resources as pkg +from six.moves.urllib import parse +from six.moves.urllib import request + from mistralclient.api.v2 import actions from mistralclient.tests.unit.v2 import base @@ -54,6 +59,26 @@ class TestActionsV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_create_with_file(self): + mock = self.mock_http_post(content={'actions': [ACTION]}) + + # The contents of action_v2.yaml must be identical to ACTION_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/action_v2.yaml' + ) + + actions = self.actions.create(path) + + self.assertIsNotNone(actions) + self.assertEqual(ACTION_DEF, actions[0].definition) + + mock.assert_called_once_with( + URL_TEMPLATE_SCOPE, + ACTION_DEF, + headers={'content-type': 'text/plain'} + ) + def test_update(self): mock = self.mock_http_put(content={'actions': [ACTION]}) @@ -68,6 +93,29 @@ class TestActionsV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_update_with_file_uri(self): + mock = self.mock_http_put(content={'actions': [ACTION]}) + + # The contents of action_v2.yaml must be identical to ACTION_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/action_v2.yaml' + ) + + # Convert the file path to file URI + uri = parse.urljoin('file:', request.pathname2url(path)) + + actions = self.actions.update(uri) + + self.assertIsNotNone(actions) + self.assertEqual(ACTION_DEF, actions[0].definition) + + mock.assert_called_once_with( + URL_TEMPLATE_SCOPE, + ACTION_DEF, + headers={'content-type': 'text/plain'} + ) + def test_list(self): mock = self.mock_http_get(content={'actions': [ACTION]}) diff --git a/mistralclient/tests/unit/v2/test_environments.py b/mistralclient/tests/unit/v2/test_environments.py index 4628ada6..a88faa4a 100644 --- a/mistralclient/tests/unit/v2/test_environments.py +++ b/mistralclient/tests/unit/v2/test_environments.py @@ -11,12 +11,17 @@ # 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 collections import OrderedDict import copy import json +import pkg_resources as pkg +from six.moves.urllib import parse +from six.moves.urllib import request + from mistralclient.api.v2 import environments from mistralclient.tests.unit.v2 import base +from mistralclient import utils ENVIRONMENT = { @@ -33,6 +38,7 @@ URL_TEMPLATE_NAME = '/environments/%s' class TestEnvironmentsV2(base.BaseClientV2Test): + def test_create(self): data = copy.deepcopy(ENVIRONMENT) @@ -46,6 +52,32 @@ class TestEnvironmentsV2(base.BaseClientV2Test): mock.assert_called_once_with(URL_TEMPLATE, json.dumps(expected_data)) + def test_create_with_json_file_uri(self): + # The contents of env_v2.json must be equivalent to ENVIRONMENT + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/env_v2.json' + ) + + # Convert the file path to file URI + uri = parse.urljoin('file:', request.pathname2url(path)) + data = OrderedDict( + utils.load_content( + utils.get_contents_if_file(uri) + ) + ) + + mock = self.mock_http_post(content=data) + file_input = {'file': uri} + env = self.environments.create(**file_input) + + 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) @@ -59,6 +91,29 @@ class TestEnvironmentsV2(base.BaseClientV2Test): mock.assert_called_once_with(URL_TEMPLATE, json.dumps(expected_data)) + def test_update_with_yaml_file(self): + # The contents of env_v2.json must be equivalent to ENVIRONMENT + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/env_v2.json' + ) + data = OrderedDict( + utils.load_content( + utils.get_contents_if_file(path) + ) + ) + + mock = self.mock_http_put(content=data) + file_input = {'file': path} + env = self.environments.update(**file_input) + + 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]}) diff --git a/mistralclient/tests/unit/v2/test_workbooks.py b/mistralclient/tests/unit/v2/test_workbooks.py index 1dc3f833..4c8487e9 100644 --- a/mistralclient/tests/unit/v2/test_workbooks.py +++ b/mistralclient/tests/unit/v2/test_workbooks.py @@ -13,6 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pkg_resources as pkg +from six.moves.urllib import parse +from six.moves.urllib import request + from mistralclient.api import base as api_base from mistralclient.api.v2 import workbooks from mistralclient.tests.unit.v2 import base @@ -79,6 +83,29 @@ class TestWorkbooksV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_create_with_file_uri(self): + mock = self.mock_http_post(content=WORKBOOK) + + # The contents of wb_v2.yaml must be identical to WB_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/wb_v2.yaml' + ) + + # Convert the file path to file URI + uri = parse.urljoin('file:', request.pathname2url(path)) + + wb = self.workbooks.create(uri) + + self.assertIsNotNone(wb) + self.assertEqual(WB_DEF, wb.definition) + + mock.assert_called_once_with( + URL_TEMPLATE, + WB_DEF, + headers={'content-type': 'text/plain'} + ) + def test_update(self): mock = self.mock_http_put(content=WORKBOOK) @@ -93,6 +120,26 @@ class TestWorkbooksV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_update_with_file(self): + mock = self.mock_http_put(content=WORKBOOK) + + # The contents of wb_v2.yaml must be identical to WB_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/wb_v2.yaml' + ) + + wb = self.workbooks.update(path) + + self.assertIsNotNone(wb) + self.assertEqual(WB_DEF, wb.definition) + + mock.assert_called_once_with( + URL_TEMPLATE, + WB_DEF, + headers={'content-type': 'text/plain'} + ) + def test_list(self): mock = self.mock_http_get(content={'workbooks': [WORKBOOK]}) @@ -145,6 +192,28 @@ class TestWorkbooksV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_validate_with_file(self): + mock = self.mock_http_post(status_code=200, + content={'valid': True}) + + # The contents of wb_v2.yaml must be identical to WB_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/wb_v2.yaml' + ) + + result = self.workbooks.validate(path) + + self.assertIsNotNone(result) + self.assertIn('valid', result) + self.assertTrue(result['valid']) + + mock.assert_called_once_with( + URL_TEMPLATE_VALIDATE, + WB_DEF, + headers={'content-type': 'text/plain'} + ) + def test_validate_failed(self): mock_result = { "valid": False, diff --git a/mistralclient/tests/unit/v2/test_workflows.py b/mistralclient/tests/unit/v2/test_workflows.py index c4fc2f02..b2a5f407 100644 --- a/mistralclient/tests/unit/v2/test_workflows.py +++ b/mistralclient/tests/unit/v2/test_workflows.py @@ -11,6 +11,11 @@ # 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 pkg_resources as pkg +from six.moves.urllib import parse +from six.moves.urllib import request + from mistralclient.api.v2 import workflows from mistralclient.tests.unit.v2 import base @@ -54,6 +59,26 @@ class TestWorkflowsV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_create_with_file(self): + mock = self.mock_http_post(content={'workflows': [WORKFLOW]}) + + # The contents of wf_v2.yaml must be identical to WF_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/wf_v2.yaml' + ) + + wfs = self.workflows.create(path) + + self.assertIsNotNone(wfs) + self.assertEqual(WF_DEF, wfs[0].definition) + + mock.assert_called_once_with( + URL_TEMPLATE_SCOPE, + WF_DEF, + headers={'content-type': 'text/plain'} + ) + def test_update(self): mock = self.mock_http_put(content={'workflows': [WORKFLOW]}) @@ -82,6 +107,29 @@ class TestWorkflowsV2(base.BaseClientV2Test): headers={'content-type': 'text/plain'} ) + def test_update_with_file_uri(self): + mock = self.mock_http_put(content={'workflows': [WORKFLOW]}) + + # The contents of wf_v2.yaml must be identical to WF_DEF + path = pkg.resource_filename( + 'mistralclient', + 'tests/unit/resources/wf_v2.yaml' + ) + + # Convert the file path to file URI + uri = parse.urljoin('file:', request.pathname2url(path)) + + wfs = self.workflows.update(uri) + + self.assertIsNotNone(wfs) + self.assertEqual(WF_DEF, wfs[0].definition) + + mock.assert_called_once_with( + URL_TEMPLATE_SCOPE, + WF_DEF, + headers={'content-type': 'text/plain'} + ) + def test_list(self): mock = self.mock_http_get(content={'workflows': [WORKFLOW]}) diff --git a/mistralclient/utils.py b/mistralclient/utils.py index d143f9ba..cdc73e2b 100644 --- a/mistralclient/utils.py +++ b/mistralclient/utils.py @@ -14,9 +14,12 @@ # limitations under the License. import json - +import os import yaml +from six.moves.urllib import parse +from six.moves.urllib import request + from mistralclient import exceptions @@ -51,3 +54,28 @@ def load_content(content): def load_file(path): with open(path, 'r') as f: return load_content(f.read()) + + +def get_contents_if_file(contents_or_file_name): + """Get the contents of a file. + + If the value passed in is a file name or file URI, return the + contents. If not, or there is an error reading the file contents, + return the value passed in as the contents. + + For example, a workflow definition will be returned if either the + workflow definition file name, or file URI are passed in, or the + actual workflow definition itself is passed in. + """ + try: + if parse.urlparse(contents_or_file_name).scheme: + definition_url = contents_or_file_name + else: + path = os.path.abspath(contents_or_file_name) + definition_url = parse.urljoin( + 'file:', + request.pathname2url(path) + ) + return request.urlopen(definition_url).read().decode('utf8') + except Exception: + return contents_or_file_name diff --git a/requirements.txt b/requirements.txt index 2c587080..fa1b7b45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ python-keystoneclient!=1.8.0,!=2.1.0,>=1.6.0 # Apache-2.0 python-openstackclient>=2.1.0 # Apache-2.0 PyYAML>=3.1.0 # MIT requests!=2.9.0,>=2.8.1 # Apache-2.0 +six>=1.9.0 # MIT