diff --git a/openstack-common.conf b/openstack-common.conf index c2d4097..984d4e4 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -2,6 +2,7 @@ # The list of modules to copy from oslo-incubator.git module=apiclient +module=cliutils module=log module=test diff --git a/requirements.txt b/requirements.txt index 1e8fa48..0bfb990 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ oslo.config>=1.2.0 iso8601>=0.1.9 requests>=1.1 python-keystoneclient>=0.6.0 +PyYAML>=3.1.0 stevedore>=0.14 diff --git a/solumclient/openstack/common/cliutils.py b/solumclient/openstack/common/cliutils.py new file mode 100644 index 0000000..264c2eb --- /dev/null +++ b/solumclient/openstack/common/cliutils.py @@ -0,0 +1,309 @@ +# Copyright 2012 Red Hat, 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. + +# W0603: Using the global statement +# W0621: Redefining name %s from outer scope +# pylint: disable=W0603,W0621 + +from __future__ import print_function + +import getpass +import inspect +import os +import sys +import textwrap + +import prettytable +import six +from six import moves + +from solumclient.openstack.common.apiclient import exceptions +from solumclient.openstack.common.gettextutils import _ +from solumclient.openstack.common import strutils +from solumclient.openstack.common import uuidutils + + +def validate_args(fn, *args, **kwargs): + """Check that the supplied args are sufficient for calling a function. + + >>> validate_args(lambda a: None) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): a + >>> validate_args(lambda a, b, c, d: None, 0, c=1) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): b, d + + :param fn: the function to check + :param arg: the positional arguments supplied + :param kwargs: the keyword arguments supplied + """ + argspec = inspect.getargspec(fn) + + num_defaults = len(argspec.defaults or []) + required_args = argspec.args[:len(argspec.args) - num_defaults] + + def isbound(method): + return getattr(method, 'im_self', None) is not None + + if isbound(fn): + required_args.pop(0) + + missing = [arg for arg in required_args if arg not in kwargs] + missing = missing[len(args):] + if missing: + raise exceptions.MissingArgs(missing) + + +def arg(*args, **kwargs): + """Decorator for CLI args. + + Example: + + >>> @arg("name", help="Name of the new entity") + ... def entity_create(args): + ... pass + """ + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + If all are empty, defaults to '' or keyword arg `default`. + """ + for arg in args: + value = os.environ.get(arg) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(func, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(func, 'arguments'): + func.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in func.arguments: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(func): + """Adds 'unauthenticated' attribute to decorated function. + + Usage: + + >>> @unauthenticated + ... def mymethod(f): + ... pass + """ + func.unauthenticated = True + return func + + +def isunauthenticated(func): + """Checks if the function does not require authentication. + + Mark such functions with the `@unauthenticated` decorator. + + :returns: bool + """ + return getattr(func, 'unauthenticated', False) + + +def print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None): + """Print a list or objects as a table, one row per object. + + :param objs: iterable of :class:`Resource` + :param fields: attributes that correspond to columns, in order + :param formatters: `dict` of callables for field formatting + :param sortby_index: index of the field for sorting table rows + :param mixed_case_fields: fields corresponding to object attributes that + have mixed case names (e.g., 'serverId') + """ + formatters = formatters or {} + mixed_case_fields = mixed_case_fields or [] + if sortby_index is None: + kwargs = {} + else: + kwargs = {'sortby': fields[sortby_index]} + pt = prettytable.PrettyTable(fields, caching=False) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + print(strutils.safe_encode(pt.get_string(**kwargs))) + + +def print_dict(dct, dict_property="Property", wrap=0): + """Print a `dict` as a table of two columns. + + :param dct: `dict` to print + :param dict_property: name of the first column + :param wrap: wrapping for the second column + """ + pt = prettytable.PrettyTable([dict_property, 'Value'], caching=False) + pt.align = 'l' + for k, v in six.iteritems(dct): + # convert dict to str to check length + if isinstance(v, dict): + v = six.text_type(v) + if wrap > 0: + v = textwrap.fill(six.text_type(v), wrap) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + pt.add_row([col1, line]) + col1 = '' + else: + pt.add_row([k, v]) + print(strutils.safe_encode(pt.get_string())) + + +def get_password(max_password_prompts=3): + """Read password from TTY.""" + verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) + pw = None + if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): + # Check for Ctrl-D + try: + for __ in moves.range(max_password_prompts): + pw1 = getpass.getpass("OS Password: ") + if verify: + pw2 = getpass.getpass("Please verify: ") + else: + pw2 = pw1 + if pw1 == pw2 and pw1: + pw = pw1 + break + except EOFError: + pass + return pw + + +def find_resource(manager, name_or_id, **find_args): + """Look for resource in a given manager. + + Used as a helper for the _find_* methods. + Example: + + def _find_hypervisor(cs, hypervisor): + #Get a hypervisor by name or ID. + return cliutils.find_resource(cs.hypervisors, hypervisor) + """ + # first try to get entity as integer id + try: + return manager.get(int(name_or_id)) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # now try to get entity as uuid + try: + tmp_id = strutils.safe_encode(name_or_id) + + if uuidutils.is_uuid_like(tmp_id): + return manager.get(tmp_id) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # for str id which is not uuid + if getattr(manager, 'is_alphanum_id_allowed', False): + try: + return manager.get(name_or_id) + except exceptions.NotFound: + pass + + try: + try: + return manager.find(human_id=name_or_id, **find_args) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + kwargs = {name_attr: name_or_id} + kwargs.update(find_args) + return manager.find(**kwargs) + except exceptions.NotFound: + msg = _("No %(name)s with a name or " + "ID of '%(name_or_id)s' exists.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = _("Multiple %(name)s matches found for " + "'%(name_or_id)s', use an ID to be more specific.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print (msg, file=sys.stderr) + sys.exit(1) diff --git a/solumclient/openstack/common/uuidutils.py b/solumclient/openstack/common/uuidutils.py new file mode 100644 index 0000000..234b880 --- /dev/null +++ b/solumclient/openstack/common/uuidutils.py @@ -0,0 +1,37 @@ +# Copyright (c) 2012 Intel Corporation. +# All Rights Reserved. +# +# 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. + +""" +UUID related utilities and helper functions. +""" + +import uuid + + +def generate_uuid(): + return str(uuid.uuid4()) + + +def is_uuid_like(val): + """Returns validation of a value as a UUID. + + For our purposes, a UUID is a canonical form string: + aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + + """ + try: + return str(uuid.UUID(val)) == val + except (TypeError, ValueError, AttributeError): + return False diff --git a/solumclient/solum.py b/solumclient/solum.py index f0bc82f..c746e33 100644 --- a/solumclient/solum.py +++ b/solumclient/solum.py @@ -32,11 +32,14 @@ Notes: from __future__ import print_function import argparse +import json import sys import six +import yaml from solumclient.common import cli_utils +from solumclient.openstack.common import cliutils from solumclient.openstack.common import strutils SOLUM_CLI_VER = "2014-01-30" @@ -47,33 +50,42 @@ class AppCommands(cli_utils.CommandsBase): def create(self): """Create an application.""" - self.parser.add_argument('plan_name', - help="Tenant/project-wide unique plan name") - self.parser.add_argument('--repo', - help="Code repository URL") - self.parser.add_argument('--build', - default='yes', - help="Build flag") + self.parser.add_argument('plan_file', + help="Plan file") args = self.parser.parse_args() - #TODO(noorul): Add REST communications - print("app create plan_name=%s repo=%s build=%s" % ( - args.plan_name, - args.repo, - args.build)) + print("app create plan_file=%s" % args.plan_file) + with open(args.plan_file) as definition_file: + definition = definition_file.read() + + # Convert yaml to json until we add yaml support in API layer. + try: + data = yaml.load(definition) + except yaml.YAMLError as exc: + print("Error in plan file: %s", str(exc)) + sys.exit(1) + + json_data = json.dumps(data) + plan = self.client.plans.create(json_data) + + fields = ['uuid', 'name', 'description'] + data = dict([(f, getattr(plan, f, '')) + for f in fields]) + cliutils.print_dict(data, wrap=72) def delete(self): """Delete an application.""" - self.parser.add_argument('plan_name', - help="Tenant/project-wide unique plan name") + self.parser.add_argument('plan_uuid', + help="Tenant/project-wide unique plan uuid") args = self.parser.parse_args() - #TODO(noorul): Add REST communications - print("app delete plan_name=%s" % ( - args.plan_name)) + print("app delete plan_uuid=%s" % args.plan_uuid) + self.client.plans.delete(plan_id=args.plan_uuid) def list(self): """List all applications.""" - #TODO(noorul): Add REST communications print("app list") + fields = ['uuid', 'name', 'description'] + response = self.client.plans.list() + cliutils.print_list(response, fields) class AssemblyCommands(cli_utils.CommandsBase): diff --git a/solumclient/tests/test_solum.py b/solumclient/tests/test_solum.py index 9f7a4a1..ecf305c 100644 --- a/solumclient/tests/test_solum.py +++ b/solumclient/tests/test_solum.py @@ -25,6 +25,7 @@ from solumclient.openstack.common.apiclient import auth from solumclient import solum from solumclient.tests import base from solumclient.v1 import assembly +from solumclient.v1 import plan FAKE_ENV = {'OS_USERNAME': 'username', 'OS_PASSWORD': 'password', @@ -117,3 +118,21 @@ class TestSolum(base.TestCase): self.assertThat(out, matchers.MatchesRegex(r, self.re_options)) + + @mock.patch.object(plan.PlanManager, "create") + def test_app_create(self, mock_app_create): + self.make_env() + required = [ + '.*?^Solum Python Command Line Client', + '.*?^app create plan_file=/dev/null' + ] + + mock_app_create.side_effect = ( + lambda plan_content: [] + ) + + out = self.shell("app create /dev/null") + for r in required: + self.assertThat(out, + matchers.MatchesRegex(r, + self.re_options)) diff --git a/solumclient/tests/v1/test_plan.py b/solumclient/tests/v1/test_plan.py new file mode 100644 index 0000000..9c49df2 --- /dev/null +++ b/solumclient/tests/v1/test_plan.py @@ -0,0 +1,184 @@ +# Copyright 2013 - Noorul Islam K M +# +# 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 solumclient.openstack.common.apiclient import fake_client +from solumclient.tests import base +from solumclient.v1 import client as solumclient +from solumclient.v1 import plan + + +plan_list = [ + { + 'uri': 'http://example.com/v1/plans/p1', + 'name': 'Example plan 1', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': ( + [{'name': 'My python app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}]), + 'services': [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}], + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' + }, + { + 'uri': 'http://example.com/v1/plans/p2', + 'name': 'Example plan 2', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': ( + [{'name': 'My java app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}]), + 'services': [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}], + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' + }, +] + +artifacts = [{'name': 'My python app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}] + +services = [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}] + +plan_fixture = { + 'uri': 'http://example.com/v1/plans/p1', + 'name': 'Example plan', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': artifacts, + 'services': services, + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' +} + +plan_file_fixture = ( + '{"artifacts": [{"artifact_type": "application.heroku", ' + '"content": {"href": "http://github.com/some/project"}, ' + '"name": "My Python App", "language-pack": "language-pack-id"}], ' + '"name": "My Python App"}') + +fixtures_list = { + '/v1/plans': { + 'GET': ( + {}, + plan_list + ), + } +} + + +fixtures_get = { + '/v1/plans/p1': { + 'GET': ( + {}, + plan_fixture + ), + } +} + + +fixtures_create = { + '/v1/plans': { + 'POST': ( + {}, + plan_fixture + ), + } +} + +fixtures_put = { + '/v1/plans/p1': { + 'PUT': ( + {}, + plan_fixture + ), + } +} + + +class PlanManagerTest(base.TestCase): + + def test_list_all(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_list) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plans = mgr.list() + self.assertEqual(len(plans), 2) + self.assertIn('Plan', repr(plans[0])) + self.assertIn('Artifact', repr(plans[0].artifacts[0])) + self.assertIn('ServiceReference', repr(plans[0].services[0])) + self.assertEqual(plans[0].uri, plan_list[0]['uri']) + self.assertEqual(plans[1].uri, plan_list[1]['uri']) + + def test_create(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_create) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.create(plan_file_fixture) + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) + + def test_get(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_get) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.get(plan_id='p1') + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) + + def test_put(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_put) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.put(plan_file_fixture, plan_id='p1') + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) diff --git a/solumclient/v1/client.py b/solumclient/v1/client.py index 24a8974..bf14346 100644 --- a/solumclient/v1/client.py +++ b/solumclient/v1/client.py @@ -15,6 +15,7 @@ from solumclient.openstack.common.apiclient import client from solumclient.v1 import assembly from solumclient.v1 import component +from solumclient.v1 import plan from solumclient.v1 import platform @@ -29,3 +30,4 @@ class Client(client.BaseClient): self.assemblies = assembly.AssemblyManager(self) self.components = component.ComponentManager(self) self.platform = platform.PlatformManager(self) + self.plans = plan.PlanManager(self) diff --git a/solumclient/v1/plan.py b/solumclient/v1/plan.py new file mode 100644 index 0000000..e4e9b90 --- /dev/null +++ b/solumclient/v1/plan.py @@ -0,0 +1,107 @@ +# Copyright 2013 - Noorul Islam K M +# +# 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 solumclient.common import base as solum_base +from solumclient.openstack.common.apiclient import base as apiclient_base + + +class Requirement(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + +class ServiceReference(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + +class Artifact(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + def _add_requirements_details(self, req_list): + return [Requirement(None, res, loaded=True) + for res in req_list if req_list] + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + if k == 'requirements': + v = self._add_requirements_details(v) + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + +class Plan(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + def _add_artifact_details(self, artf_list): + return [Artifact(None, res, loaded=True) + for res in artf_list if artf_list] + + def _add_services_details(self, serv_list): + return [ServiceReference(None, res, loaded=True) + for res in serv_list if serv_list] + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + if k == 'artifacts': + v = self._add_artifact_details(v) + elif k == 'services': + v = self._add_services_details(v) + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + +class PlanManager(solum_base.CrudManager): + resource_class = Plan + collection_key = 'plans' + key = 'plan' + + def list(self, **kwargs): + return super(PlanManager, self).list(base_url="/v1", **kwargs) + + def create(self, plan, **kwargs): + kwargs = self._filter_kwargs(kwargs) + kwargs['data'] = plan + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs['headers']['Content-Type'] = 'application/json' + body = self.client.post(self.build_url(base_url="/v1", **kwargs), + **kwargs).json() + return self.resource_class(self, body) + + def get(self, **kwargs): + return super(PlanManager, self).get(base_url="/v1", **kwargs) + + def put(self, plan, **kwargs): + kwargs = self._filter_kwargs(kwargs) + kwargs['data'] = plan + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs['headers']['Content-Type'] = 'application/json' + body = self.client.put(self.build_url(base_url="/v1", **kwargs), + **kwargs).json() + return self.resource_class(self, body) + + def delete(self, **kwargs): + return super(PlanManager, self).delete(base_url="/v1", **kwargs)