diff --git a/heatclient/common/utils.py b/heatclient/common/utils.py index 27fc02ed..5a6efc8c 100644 --- a/heatclient/common/utils.py +++ b/heatclient/common/utils.py @@ -71,6 +71,10 @@ def json_formatter(js): separators=(', ', ': ')) +def yaml_formatter(js): + return yaml.safe_dump(js, default_flow_style=False) + + def text_wrap_formatter(d): return '\n'.join(textwrap.wrap(d or '', 55)) diff --git a/heatclient/osc/__init__.py b/heatclient/osc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/heatclient/osc/plugin.py b/heatclient/osc/plugin.py new file mode 100644 index 00000000..dd980efc --- /dev/null +++ b/heatclient/osc/plugin.py @@ -0,0 +1,59 @@ +# 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 logging + +from openstackclient.common import utils + +LOG = logging.getLogger(__name__) + +DEFAULT_ORCHESTRATION_API_VERSION = '1' +API_VERSION_OPTION = 'os_orchestration_api_version' +API_NAME = 'orchestration' +API_VERSIONS = { + '1': 'heatclient.v1.client.Client', +} + + +def make_client(instance): + """Returns an orchestration service client""" + heat_client = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + API_VERSIONS) + LOG.debug('Instantiating orchestration client: %s', heat_client) + + client = heat_client( + endpoint=instance.get_endpoint_for_service_type('orchestration'), + session=instance.session, + auth_url=instance._auth_url, + username=instance._username, + password=instance._password, + region_name=instance._region_name, + ) + + return client + + +def build_option_parser(parser): + """Hook to add global options""" + parser.add_argument( + '--os-orchestration-api-version', + metavar='', + default=utils.env( + 'OS_ORCHESTRATION_API_VERSION', + default=DEFAULT_ORCHESTRATION_API_VERSION), + help='Orchestration API version, default=' + + DEFAULT_ORCHESTRATION_API_VERSION + + ' (Env: OS_ORCHESTRATION_API_VERSION)') + return parser diff --git a/heatclient/osc/v1/__init__.py b/heatclient/osc/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py new file mode 100644 index 00000000..c6546277 --- /dev/null +++ b/heatclient/osc/v1/stack.py @@ -0,0 +1,84 @@ +# 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. +# + +"""Orchestration v1 Stack action implementations""" + +import logging + +from cliff import show +from openstackclient.common import exceptions as exc +from openstackclient.common import utils + +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc + + +class ShowStack(show.ShowOne): + """Show stack details""" + + log = logging.getLogger(__name__ + ".ShowStack") + + def get_parser(self, prog_name): + parser = super(ShowStack, self).get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help='Stack to display (name or ID)', + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _show_stack(heat_client, stack_id=parsed_args.stack, + format=parsed_args.formatter) + + +def _show_stack(heat_client, stack_id, format): + try: + data = heat_client.stacks.get(stack_id=stack_id) + except heat_exc.HTTPNotFound: + raise exc.CommandError('Stack not found: %s' % stack_id) + else: + + columns = [ + 'id', + 'stack_name', + 'description', + 'creation_time', + 'updated_time', + 'stack_status', + 'stack_status_reason', + 'parameters', + 'outputs', + 'links', + ] + exclude_columns = ('template_description',) + for key in data.to_dict(): + # add remaining columns without an explicit order + if key not in columns and key not in exclude_columns: + columns.append(key) + formatters = {} + complex_formatter = None + if format in 'table': + complex_formatter = heat_utils.yaml_formatter + elif format in ('shell', 'value', 'html'): + complex_formatter = heat_utils.json_formatter + if complex_formatter: + formatters['parameters'] = complex_formatter + formatters['outputs'] = complex_formatter + formatters['links'] = complex_formatter + + return columns, utils.get_item_properties(data, columns, + formatters=formatters) diff --git a/heatclient/tests/unit/osc/__init__.py b/heatclient/tests/unit/osc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/heatclient/tests/unit/osc/fakes.py b/heatclient/tests/unit/osc/fakes.py new file mode 100644 index 00000000..73865906 --- /dev/null +++ b/heatclient/tests/unit/osc/fakes.py @@ -0,0 +1,237 @@ +# Copyright 2013 Nebula 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 +import sys + +import requests +import six + + +AUTH_TOKEN = "foobar" +AUTH_URL = "http://0.0.0.0" +USERNAME = "itchy" +PASSWORD = "scratchy" +TEST_RESPONSE_DICT = { + "access": { + "metadata": { + "is_admin": 0, + "roles": [ + "1234", + ] + }, + "serviceCatalog": [ + { + "endpoints": [ + { + "adminURL": AUTH_URL + "/v2.0", + "id": "1234", + "internalURL": AUTH_URL + "/v2.0", + "publicURL": AUTH_URL + "/v2.0", + "region": "RegionOne" + } + ], + "endpoints_links": [], + "name": "keystone", + "type": "identity" + } + ], + "token": { + "expires": "2035-01-01T00:00:01Z", + "id": AUTH_TOKEN, + "issued_at": "2013-01-01T00:00:01.692048", + "tenant": { + "description": None, + "enabled": True, + "id": "1234", + "name": "testtenant" + } + }, + "user": { + "id": "5678", + "name": USERNAME, + "roles": [ + { + "name": "testrole" + }, + ], + "roles_links": [], + "username": USERNAME + } + } +} +TEST_RESPONSE_DICT_V3 = { + "token": { + "audit_ids": [ + "a" + ], + "catalog": [ + ], + "expires_at": "2034-09-29T18:27:15.978064Z", + "extras": {}, + "issued_at": "2014-09-29T17:27:15.978097Z", + "methods": [ + "password" + ], + "project": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": "bbb", + "name": "project" + }, + "roles": [ + ], + "user": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": "aaa", + "name": USERNAME + } + } +} +TEST_VERSIONS = { + "versions": { + "values": [ + { + "id": "v3.0", + "links": [ + { + "href": AUTH_URL, + "rel": "self" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/vnd.openstack.identity-v3+json" + }, + { + "base": "application/xml", + "type": "application/vnd.openstack.identity-v3+xml" + } + ], + "status": "stable", + "updated": "2013-03-06T00:00:00Z" + }, + { + "id": "v2.0", + "links": [ + { + "href": AUTH_URL, + "rel": "self" + }, + { + "href": "http://docs.openstack.org/", + "rel": "describedby", + "type": "text/html" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/vnd.openstack.identity-v2.0+json" + }, + { + "base": "application/xml", + "type": "application/vnd.openstack.identity-v2.0+xml" + } + ], + "status": "stable", + "updated": "2014-04-17T00:00:00Z" + } + ] + } +} + + +class FakeStdout: + def __init__(self): + self.content = [] + + def write(self, text): + self.content.append(text) + + def make_string(self): + result = '' + for line in self.content: + result = result + line + return result + + +class FakeApp(object): + def __init__(self, _stdout): + self.stdout = _stdout + self.client_manager = None + self.stdin = sys.stdin + self.stdout = _stdout or sys.stdout + self.stderr = sys.stderr + + +class FakeClient(object): + def __init__(self, **kwargs): + self.endpoint = kwargs['endpoint'] + self.token = kwargs['token'] + + +class FakeClientManager(object): + def __init__(self): + self.compute = None + self.identity = None + self.image = None + self.object_store = None + self.volume = None + self.network = None + self.session = None + self.auth_ref = None + + +class FakeModule(object): + def __init__(self, name, version): + self.name = name + self.__version__ = version + + +class FakeResource(object): + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + setattr(self, k, v) + + def __repr__(self): + reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and + k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + +class FakeResponse(requests.Response): + def __init__(self, headers={}, status_code=200, data=None, encoding=None): + super(FakeResponse, self).__init__() + + self.status_code = status_code + + self.headers.update(headers) + self._content = json.dumps(data) + if not isinstance(self._content, six.binary_type): + self._content = self._content.encode() diff --git a/heatclient/tests/unit/osc/utils.py b/heatclient/tests/unit/osc/utils.py new file mode 100644 index 00000000..be0ca815 --- /dev/null +++ b/heatclient/tests/unit/osc/utils.py @@ -0,0 +1,93 @@ +# Copyright 2012-2013 OpenStack Foundation +# Copyright 2013 Nebula 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 os + +import fixtures +import sys +import testtools + +from heatclient.tests.unit.osc import fakes + + +class TestCase(testtools.TestCase): + def setUp(self): + testtools.TestCase.setUp(self) + + if (os.environ.get("OS_STDOUT_CAPTURE") == "True" or + os.environ.get("OS_STDOUT_CAPTURE") == "1"): + stdout = self.useFixture(fixtures.StringStream("stdout")).stream + self.useFixture(fixtures.MonkeyPatch("sys.stdout", stdout)) + + if (os.environ.get("OS_STDERR_CAPTURE") == "True" or + os.environ.get("OS_STDERR_CAPTURE") == "1"): + stderr = self.useFixture(fixtures.StringStream("stderr")).stream + self.useFixture(fixtures.MonkeyPatch("sys.stderr", stderr)) + + def assertNotCalled(self, m, msg=None): + """Assert a function was not called""" + + if m.called: + if not msg: + msg = 'method %s should not have been called' % m + self.fail(msg) + + # 2.6 doesn't have the assert dict equals so make sure that it exists + if tuple(sys.version_info)[0:2] < (2, 7): + + def assertIsInstance(self, obj, cls, msg=None): + """self.assertTrue(isinstance(obj, cls)), with a nicer message""" + + if not isinstance(obj, cls): + standardMsg = '%s is not an instance of %r' % (obj, cls) + self.fail(self._formatMessage(msg, standardMsg)) + + def assertDictEqual(self, d1, d2, msg=None): + # Simple version taken from 2.7 + self.assertIsInstance(d1, dict, + 'First argument is not a dictionary') + self.assertIsInstance(d2, dict, + 'Second argument is not a dictionary') + if d1 != d2: + if msg: + self.fail(msg) + else: + standardMsg = '%r != %r' % (d1, d2) + self.fail(standardMsg) + + +class TestCommand(TestCase): + """Test cliff command classes""" + + def setUp(self): + super(TestCommand, self).setUp() + # Build up a fake app + self.fake_stdout = fakes.FakeStdout() + self.app = fakes.FakeApp(self.fake_stdout) + self.app.client_manager = fakes.FakeClientManager() + + def check_parser(self, cmd, args, verify_args): + cmd_parser = cmd.get_parser('check_parser') + try: + parsed_args = cmd_parser.parse_args(args) + except SystemExit: + raise Exception("Argument parse failed") + for av in verify_args: + attr, value = av + if attr: + self.assertIn(attr, parsed_args) + self.assertEqual(getattr(parsed_args, attr), value) + return parsed_args diff --git a/heatclient/tests/unit/osc/v1/__init__.py b/heatclient/tests/unit/osc/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/heatclient/tests/unit/osc/v1/fakes.py b/heatclient/tests/unit/osc/v1/fakes.py new file mode 100644 index 00000000..1888c135 --- /dev/null +++ b/heatclient/tests/unit/osc/v1/fakes.py @@ -0,0 +1,45 @@ +# Copyright 2014 OpenStack Foundation +# +# 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 mock + +from heatclient.tests.unit.osc import fakes +from heatclient.tests.unit.osc import utils + + +class FakeOrchestrationv1Client(object): + def __init__(self, **kwargs): + self.http_client = mock.Mock() + self.http_client.auth_token = kwargs['token'] + self.http_client.management_url = kwargs['endpoint'] + self.stacks = mock.Mock() + self.stacks.list = mock.Mock(return_value=[]) + self.resources = fakes.FakeResource(None, {}) + self.resource_types = fakes.FakeResource(None, {}) + self.events = fakes.FakeResource(None, {}) + self.actions = fakes.FakeResource(None, {}) + self.build_info = fakes.FakeResource(None, {}) + self.software_deployments = fakes.FakeResource(None, {}) + self.software_configs = fakes.FakeResource(None, {}) + + +class TestOrchestrationv1(utils.TestCommand): + def setUp(self): + super(TestOrchestrationv1, self).setUp() + + self.app.client_manager.orchestration = FakeOrchestrationv1Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) diff --git a/heatclient/tests/unit/osc/v1/test_stack.py b/heatclient/tests/unit/osc/v1/test_stack.py new file mode 100644 index 00000000..ca6b1e0b --- /dev/null +++ b/heatclient/tests/unit/osc/v1/test_stack.py @@ -0,0 +1,83 @@ +# 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 mock +import testscenarios + +from heatclient.osc.v1 import stack +from heatclient.tests.unit.osc.v1 import fakes as orchestration_fakes +from heatclient.v1 import stacks + +load_tests = testscenarios.load_tests_apply_scenarios + + +class TestStack(orchestration_fakes.TestOrchestrationv1): + def setUp(self): + super(TestStack, self).setUp() + self.mock_client = self.app.client_manager.orchestration + self.stack_client = self.app.client_manager.orchestration.stacks + + +class TestStackShow(TestStack): + + scenarios = [ + ('table', dict( + format='table')), + ('shell', dict( + format='shell')), + ('value', dict( + format='value')), + ] + + get_response = {"stack": { + "disable_rollback": True, + "description": "This is a\ndescription\n", + "parent": None, + "tags": None, + "stack_name": "a", + "stack_user_project_id": "02ad9bd403d44ff9ba128cf9ce77f989", + "stack_status_reason": "Stack UPDATE completed successfully", + "creation_time": "2015-08-04T04:46:10", + "links": [{ + "href": "http://192.0.2.1:8004/v1/5dcd28/stacks/a/4af43781", + "rel": "self" + }], + "capabilities": [], + "notification_topics": [], + "updated_time": "2015-08-05T21:33:28", + "timeout_mins": None, + "stack_status": "UPDATE_COMPLETE", + "stack_owner": None, + "parameters": { + "OS::project_id": "e0e5e140c5854c259a852621b65dcd28", + "OS::stack_id": "4af43781", + "OS::stack_name": "a" + }, + "id": "4af43781", + "outputs": [], + "template_description": "This is a\ndescription\n"} + } + + def setUp(self): + super(TestStackShow, self).setUp() + self.cmd = stack.ShowStack(self.app, None) + + def test_stack_show(self): + arglist = ['--format', self.format, 'my_stack'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.stack_client.get = mock.Mock( + return_value=stacks.Stack(None, self.get_response)) + self.cmd.take_action(parsed_args) + self.stack_client.get.assert_called_with(**{ + 'stack_id': 'my_stack', + }) diff --git a/heatclient/tests/unit/test_utils.py b/heatclient/tests/unit/test_utils.py index f1950d1d..762ddfcf 100644 --- a/heatclient/tests/unit/test_utils.py +++ b/heatclient/tests/unit/test_utils.py @@ -144,6 +144,12 @@ class ShellTest(testtools.TestCase): self.assertEqual(u'{\n "Uni": "test\u2665"\n}', utils.json_formatter({"Uni": u"test\u2665"})) + def test_yaml_formatter(self): + self.assertEqual('null\n...\n', utils.yaml_formatter(None)) + self.assertEqual('{}\n', utils.yaml_formatter({})) + self.assertEqual('foo: bar\n', + utils.yaml_formatter({"foo": "bar"})) + def test_text_wrap_formatter(self): self.assertEqual('', utils.text_wrap_formatter(None)) self.assertEqual('', utils.text_wrap_formatter('')) diff --git a/requirements.txt b/requirements.txt index e329db74..4816cff2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ Babel>=1.3 pbr>=1.6 +cliff>=1.14.0 # Apache-2.0 argparse iso8601>=0.1.9 PrettyTable<0.8,>=0.7 @@ -12,6 +13,7 @@ oslo.serialization>=1.4.0 # Apache-2.0 oslo.utils>=2.4.0 # Apache-2.0 python-keystoneclient!=1.8.0,>=1.6.0 python-swiftclient>=2.2.0 +python-openstackclient>=1.5.0 PyYAML>=3.1.0 requests!=2.8.0,>=2.5.2 six>=1.9.0 diff --git a/setup.cfg b/setup.cfg index bbae0a8e..00b33a5c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,13 @@ packages = console_scripts = heat = heatclient.shell:main +openstack.cli.extension = + orchestration = heatclient.osc.plugin + +openstack.orchestration.v1 = + stack_show = heatclient.osc.v1.stack:ShowStack + + [global] setup-hooks = pbr.hooks.setup_hook