From: janne.suominen Date: Wed, 8 May 2019 12:02:46 +0000 (+0300) Subject: Seed code for hostcli X-Git-Url: https://gerrit.akraino.org/r/gitweb?p=ta%2Fhostcli.git;a=commitdiff_plain;h=9a52ce01e2188bc347d767481bb1a5b2ca3992df Seed code for hostcli Seed code for hostcli Change-Id: I964d71697266410830d99122f64b6ff1511c0b40 Signed-off-by: janne.suominen --- diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..93fa916 --- /dev/null +++ b/README.rst @@ -0,0 +1,134 @@ +:: + + Copyright 2019 Nokia + + 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. + + +============================== +Host CLI +============================== + +.. raw:: pdf + + PageBreak + +.. sectnum:: + +.. contents:: + +.. raw:: pdf + + PageBreak + +Introduction +============ + +Cloud infratructure is openstack based, openstack services already utilize +a common framework for openstack own commands. These commands are accessed via +the **openstack** command. The syntax used in this command is the following: + +:: + + openstack [] + + +Below is an example of some **openstack** commands. + +:: + + openstack server list + + openstack server show + + openstack network list + + ... + +The **openstack** command is implemented ontop of the python **cliff** framework. + +**cliff** framework supports the following: + +- structure the commands tokens. + +- map command tokens to command handlers. + +- parse command arguments. + +- format commands output. + +- bash auto-completion of commands. + +A well integrated platform is expected to provide a common way to access the +interfaces provided by the platform. As such cloud infrastructure utilizes +the same **cliff** framework to implement the infrastrcture own middleware CLIs. + +The infrastrcutre CLIs can be accessed via the **hostcli** command. + +The syntax used in this command is the following: + +:: + + hostcli [] + +Below is an example of some **hostcli** commands. + +:: + + hostcli has show nodes + + hostcli has show services --node + + hostcli motd show + + hostcli motd set --motd + + ... + +In addition to providing the common look and feel for the end user, the +*hostcli* takes care of authorizing with keystone and provide a token +for authentication usage + +The following diagram describes the highlevel design for **hostcli** CLI +frameowk. + +.. image:: docs/hostcli.png + + +When using yarf restful framework to implement the backend for middleware +the frame provides a RestRequest class to make the authentication transperant +to a module. + +Example of using this request: + +.. code:: python + + def take_action(self, parsed_args): + req = self.app.client_manager.resthandler + ret = req.get("has/v1/cluster", decode_json=True) + status = ret['data'] + columns = ('admin-state', + 'running-state', + 'role' + ) + data = (status['admin-state'], + status['running-state'], + status['role'] + ) + return (columns, data) + +The beef is ot get the resthandler from client_manager that is defined in the app. +This will return the object RestRequest for you that has the HTTP operations predefined. +The only thing needed is the path of the url. The actual address should not be defined, +since it's extracted from the keystone session from the endpoints. diff --git a/docs/hostcli.asciio b/docs/hostcli.asciio new file mode 100644 index 0000000..53b6800 Binary files /dev/null and b/docs/hostcli.asciio differ diff --git a/docs/hostcli.png b/docs/hostcli.png new file mode 100644 index 0000000..6e089c8 Binary files /dev/null and b/docs/hostcli.png differ diff --git a/hostcli.spec b/hostcli.spec new file mode 100644 index 0000000..1f81310 --- /dev/null +++ b/hostcli.spec @@ -0,0 +1,58 @@ +# Copyright 2019 Nokia +# +# 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. +# + +Name: hostcli +Version: %{_version} +Release: 1%{?dist} +Summary: Contains code for the hostcli framework. +License: %{_platform_licence} +Source0: %{name}-%{version}.tar.gz +Vendor: %{_platform_vendor} +BuildArch: noarch + +Requires: python-cliff python-pip python2-osc-lib python-openstackclient +BuildRequires: python +BuildRequires: python-setuptools + + +%description +This RPM contains source code for the hostcli framework. + +%prep +%autosetup + +%install +cd src && python setup.py install --root %{buildroot} --no-compile --install-purelib %{_python_site_packages_path} --install-scripts %{_platform_bin_path} && cd - + + +%files +%{_python_site_packages_path}/hostcli* +#%{_python_site_packages_path}/hostcli.* +%{_platform_bin_path}/hostcli + +%pre + +%post + + +%preun + +%postun + +%clean +rm -rf %{buildroot} + +# TIPS: +# File /usr/lib/rpm/macros contains useful variables which can be used for example to define target directory for man page. diff --git a/src/hostcli/__init__.py b/src/hostcli/__init__.py new file mode 100644 index 0000000..f035b4a --- /dev/null +++ b/src/hostcli/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2019 Nokia +# +# 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. +# + diff --git a/src/hostcli/helper.py b/src/hostcli/helper.py new file mode 100644 index 0000000..9f569c4 --- /dev/null +++ b/src/hostcli/helper.py @@ -0,0 +1,307 @@ +# Copyright 2019 Nokia +# +# 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 sys +import re +from datetime import datetime +from dateutil import tz +from cliff.show import ShowOne +from cliff.lister import Lister +from cliff.command import Command + + +DEFAULT = 'default' +ALL = 'all' +DISPLAY = 'display' +HELP = 'help' +SORT = 'sort' +DATA = 'data' +CHOICES = 'choices' # allowed values for an argument (coming from argparse 'choices' facility) +VALUES = 'values' # allowed values for an argument, even if the argument supports adding multiple instances +FIELDS = 'fields' # filtered list of expected columns in the response (translates to argparse's 'columns' of) +COLUMNS = 'columns' # same as fields +DETAILED = 'detailed' # Makes the command to show all accessible details of the requsted objects. + # Should not be positional (so should not be the first in the arguments list) +TIME = 'time' +UTC = 'utc' + + +class HelperBase(object): + """Helper base class validating arguments and doing the business logic (send query and receive and process table in response)""" + def __init__(self): + self.operation = 'get' + self.endpoint = '' + self.arguments = [] + self.columns = [] + self.detailed = [] + self.fieldmap = {} + self.message = '' + self.no_positional = False + self.mandatory_positional = False + self.usebody = False + self.resource_prefix = '' + self.default_sort = None + self.positional_count = 1 # how many mandatory arguments are + + def get_parser_with_arguments(self, parser): + args = self.arguments[:] + if self.no_positional is False: + for i in range (0, self.positional_count): + first = args.pop(0) + parser.add_argument(first, + metavar=first.upper(), + nargs=None if self.mandatory_positional else '?', + default=self.fieldmap[first].get(DEFAULT, ALL), + help=self.fieldmap[first][HELP]) + for e in args: + # This is very similar to 'choices' facility of argparse, however it allows multiple choices combined... + multichoices = '' + default = self.fieldmap[e].get(DEFAULT, ALL) + if e in [DETAILED, UTC]: + parser.add_argument('--%s' % e, dest=e, action='store_true', help=self.fieldmap[e][HELP]) + continue + if e == SORT: + # ...and is needed here to list the allowed arguments in the help + multichoices = ' [%s]' % ','.join([self.fieldmap[i][DISPLAY] for i in self.columns]) + if self.default_sort: + default = '%s:%s' % (self.fieldmap[self.default_sort[0]][DISPLAY], self.default_sort[1]) + elif VALUES in self.fieldmap[e]: + multichoices = ' [%s]' % ','.join(self.fieldmap[e][VALUES]) + parser.add_argument('--%s' % e, + dest=e, + metavar=e.upper(), + required=False, + default=default, + type=str, + choices=self.fieldmap[e].get(CHOICES, None), + help=self.fieldmap[e][HELP] + multichoices) + return parser + + def send_receive(self, app, parsed_args): + parsed_args = self.validate_parameters(parsed_args) + if parsed_args.fields: + self.arguments.append(FIELDS) + arguments = {k: v for k, v in sorted(vars(parsed_args).items()) if k in self.arguments and + k != COLUMNS and + v != ALL and + v is not False} + if not arguments: + arguments = None + req = app.client_manager.resthandler + response = req._operation(self.operation, + '%s%s' %(self.resource_prefix, self.endpoint), + arguments if self.usebody else None, + None if self.usebody else arguments, + False) + if not response.ok: + raise Exception('Request response is not OK (%s)' % response.reason) + result = response.json() + if 0 != result['code']: + raise Exception(result['description']) + return result + + def validate_parameters(self, args): + if 'starttime' in self.arguments: + args.starttime = HelperBase.convert_timezone_to_utc(args.starttime) + if 'endtime' in self.arguments: + args.endtime = HelperBase.convert_timezone_to_utc(args.endtime) + args.fields = ALL + if hasattr(args, COLUMNS): + args.columns = list(set(j for i in args.columns for j in i.split(','))) + if args.columns: + args.fields = ','.join([self.get_key_by_value(k) for k in sorted(args.columns)]) + for a in self.arguments: + argval = getattr(args, a) + if isinstance(argval, str): + for p in argval.split(','): + if argval != ALL and VALUES in self.fieldmap[a] and p not in self.fieldmap[a][VALUES]: + raise Exception('%s is not supported by %s argument' % (p, a)) + return args + + @staticmethod + def validate_datetime(dt): + if dt == ALL: + return dt + formats = ['%Y-%m-%dT%H:%M:%S.%fZ', + '%Y-%m-%dT%H:%M:%SZ', + '%Y-%m-%dT%H:%MZ', + '%Y-%m-%dZ'] + for f in formats: + try: + retval = dt + if 'Z' != retval[-1]: + retval += 'Z' + retval = datetime.strptime(retval, f).__str__() + retval = '%s.000' % retval if len(retval) <= 19 else retval[:-3] + return retval.replace(' ', 'T') + 'Z' + except ValueError: + pass + raise Exception('Datetime format (%s) is not supported' % dt) + + @staticmethod + def convert_utc_to_timezone(timestr): + timestr = timestr.replace('Z', '') + # max resolution for strptime is microsec + if len(timestr) > 26: + timestr = timestr[:26] + from_zone = tz.tzutc() + to_zone = tz.tzlocal() + utc = datetime.strptime(HelperBase.validate_datetime(timestr + 'Z'), '%Y-%m-%dT%H:%M:%S.%fZ') + utc = utc.replace(tzinfo=from_zone) + ret = str(utc.astimezone(to_zone)) + return ret[:23] if '.' in ret else ret[:19] + '.000' + + @staticmethod + def convert_timezone_to_utc(timestr): + if timestr[-1] == 'Z' or timestr == ALL: + # we assume that UTC will always have a Z character at the end + return timestr + formats = ['%Y-%m-%dT%H:%M:%S.%f', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M', + '%Y-%m-%d'] + origstr = timestr + timestr = timestr.replace(' ', 'T') + if len(timestr) > 26: + timestr = timestr[:26] + from_zone = tz.tzlocal() + to_zone = tz.tzutc() + for f in formats: + try: + localtime = datetime.strptime(timestr, f) + localtime = localtime.replace(tzinfo=from_zone) + ret = str(localtime.astimezone(to_zone)) + retval = ret[:23] if '.' in ret else ret[:19] + '.000' + return retval.replace(' ', 'T') + 'Z' + except ValueError: + pass + raise Exception('Datetime format (%s) is not supported' % origstr) + + def filter_columns(self, args): + if getattr(args, DETAILED, False) is True: + self.columns.extend(self.detailed) + if ALL != args.fields: + for i in range(len(self.columns) - 1, -1, -1): + if self.columns[i] not in args.fields: + del self.columns[i] + return [self.fieldmap[f][DISPLAY] for f in self.columns] + + def get_key_by_value(self, val): + for k, v in self.fieldmap.items(): + if DISPLAY in v and val == v[DISPLAY]: + return k + raise Exception('No column named %s' % val) + + def get_sorted_keys(self, parsed_args, data): + keylist = data.keys() + if hasattr(parsed_args, SORT): + sortexp = parsed_args.sort + if sortexp != ALL: + # The next one generates two lists, one with the field names (to be sorted), + # and another with the directions. True if reversed, false otherwise + # also if no direction is added for a field, then it adds an ':asc' by default. + skeys, sdir = zip(*[(self.get_key_by_value(x[0]), False if 'asc' in x[1].lower() else True) + for x in (('%s:asc' % x).split(":") for x in reversed(sortexp.split(',')))]) + for k, d in zip(skeys, sdir): + keylist.sort(key=lambda x: data[x][k], reverse=d) + return keylist + + @staticmethod + def construct_message(text, result): + p = re.compile('\#\#(\w+)') + while True: + m = p.search(text) + if not m: + break + text = p.sub(result[DATA][m.group(1)], text, 1) + return '%s\n' % text + + +class ListerHelper(Lister, HelperBase): + """Helper class for Lister""" + def __init__(self, app, app_args, cmd_name=None): + Lister.__init__(self, app, app_args, cmd_name) + HelperBase.__init__(self) + + def get_parser(self, prog_name): + parser = super(ListerHelper, self).get_parser(prog_name) + return self.get_parser_with_arguments(parser) + + def take_action(self, parsed_args): + try: + result = self.send_receive(self.app, parsed_args) + header = self.filter_columns(parsed_args) + data = [] + for k in self.get_sorted_keys(parsed_args, result[DATA]): + row = [HelperBase.convert_utc_to_timezone(result[DATA][k][i]) + if not getattr(parsed_args, UTC, False) and i == TIME + else result[DATA][k][i] for i in self.columns] + data.append(row) + if self.message: + self.app.stdout.write(self.message + '\n') + return header, data + except Exception as exp: + self.app.stderr.write('Failed with error:\n%s\n' % str(exp)) + sys.exit(1) + + +class ShowOneHelper(ShowOne, HelperBase): + """Helper class for ShowOne""" + def __init__(self, app, app_args, cmd_name=None): + ShowOne.__init__(self, app, app_args, cmd_name) + HelperBase.__init__(self) + + def get_parser(self, prog_name): + parser = super(ShowOneHelper, self).get_parser(prog_name) + return self.get_parser_with_arguments(parser) + + def take_action(self, parsed_args): + try: + result = self.send_receive(self.app, parsed_args) + header = self.filter_columns(parsed_args) + sorted_keys = self.get_sorted_keys(parsed_args, result[DATA]) + if self.message: + self.app.stdout.write(self.message + '\n') + for k in sorted_keys: + data = [HelperBase.convert_utc_to_timezone(result[DATA][k][i]) + if not getattr(parsed_args, UTC, False) and i == TIME + else result[DATA][k][i] for i in self.columns] + if k != sorted_keys[-1]: + self.formatter.emit_one(header, data, self.app.stdout, parsed_args) + self.app.stdout.write('\n') + return header, data + except Exception as exp: + self.app.stderr.write('Failed with error:\n%s\n' % str(exp)) + sys.exit(1) + + +class CommandHelper(Command, HelperBase): + """Helper class for Command""" + def __init__(self, app, app_args, cmd_name=None): + Command.__init__(self, app, app_args, cmd_name) + HelperBase.__init__(self) + + def get_parser(self, prog_name): + parser = super(CommandHelper, self).get_parser(prog_name) + return self.get_parser_with_arguments(parser) + + def take_action(self, parsed_args): + try: + result = self.send_receive(self.app, parsed_args) + if self.message: + self.app.stdout.write(HelperBase.construct_message(self.message, result)) + except Exception as exp: + self.app.stderr.write('Failed with error:\n%s\n' % str(exp)) + sys.exit(1) diff --git a/src/hostcli/main.py b/src/hostcli/main.py new file mode 100644 index 0000000..ba5bbd8 --- /dev/null +++ b/src/hostcli/main.py @@ -0,0 +1,121 @@ +# Copyright 2019 Nokia +# +# 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 +import sys +import time + +from cliff.commandmanager import CommandManager + +from keystoneauth1.exceptions.http import BadGateway + +from osc_lib import shell +from osc_lib import utils +from osc_lib import clientmanager +from osc_lib.api import auth +from osc_lib.cli import client_config as cloud_config + +from openstackclient.i18n import _ + +from hostcli import resthandler + + +class HOSTCLI(shell.OpenStackShell): + LOG = logging.getLogger(__name__) + def __init__(self): + super(HOSTCLI, self).__init__( + description='HOSTCLI', + version='0.1', + command_manager=CommandManager('hostcli.commands') + ) + self.failure_count = 30 + + def build_option_parser(self, description, version): + parser = super(HOSTCLI, self).build_option_parser( + description, + version) + parser = auth.build_auth_plugins_option_parser(parser) + #HACK: Add the api version so that we wont use version 2 + #This part comes from openstack module so it cannot be imported + parser.add_argument('--os-identity-api-version', + metavar='', + default=utils.env('OS_IDENTITY_API_VERSION'), + help=_('Identity API version, default=%s ' + '(Env: OS_IDENTITY_API_VERSION)') % 3, + ) + + return parser + + def initialize_app(self, argv): + self.LOG.debug('initialize_app') + super(HOSTCLI, self).initialize_app(argv) + try: + self.cloud_config = cloud_config.OSC_Config( + override_defaults={ + 'interface': None, + 'auth_type': self._auth_type, + }, + pw_func=shell.prompt_for_password, + ) + except (IOError, OSError): + self.log.critical("Could not read clouds.yaml configuration file") + self.print_help_if_requested() + raise + if not self.options.debug: + self.options.debug = None + + setattr(clientmanager.ClientManager, + resthandler.API_NAME, + clientmanager.ClientCache(getattr(resthandler, 'make_instance'))) + self.client_manager = clientmanager.ClientManager( + cli_options=self.cloud, + api_version=self.api_version, + pw_func=shell.prompt_for_password, + ) + + def _final_defaults(self): + + super(HOSTCLI, self)._final_defaults() + # Set the default plugin to token_endpoint if url and token are given + if self.options.url and self.options.token: + # Use service token authentication + self._auth_type = 'token_endpoint' + else: + self._auth_type = 'password' + + + def prepare_to_run_command(self, cmd): + self.LOG.debug('prepare_to_run_command %s', cmd.__class__.__name__) + error = Exception() + for count in range(0, self.failure_count): + try: + return super(HOSTCLI, self).prepare_to_run_command(cmd) + except BadGateway as error: + self.LOG.debug('Got BadGateway %s, counter %d', str(error), count) + time.sleep(2) + raise error + + def clean_up(self, cmd, result, err): + self.LOG.debug('clean_up %s', cmd.__class__.__name__) + if err: + self.LOG.debug('got an error: %s', err) + +def main(argv=sys.argv[1:]): + hostcli = HOSTCLI() + return hostcli.run(argv) + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/src/hostcli/resthandler.py b/src/hostcli/resthandler.py new file mode 100644 index 0000000..0f1d16d --- /dev/null +++ b/src/hostcli/resthandler.py @@ -0,0 +1,132 @@ +# Copyright 2019 Nokia +# +# 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 +import requests +import os + +API_NAME = 'resthandler' +LOG = logging.getLogger(__name__) + +def make_instance(instance): + return RestRequest(instance) + +class RestRequest(object): + """ RestRequest object + This module can be used in the context of hostcli rest implementations. + Example usage is: + def take_action(self, parsed_args): + req = self.app.client_manager.resthandler + ret = req.get("has/v1/cluster", decode_json=True) + status = ret['data'] + columns = ('admin-state', + 'running-state', + 'role' + ) + data = (status['admin-state'], + status['running-state'], + status['role'] + ) + return (columns, data) + Why: + This module will fill the needed information for authentication. + The authentication will be based on keystone. + Notes: + The object will fill the prefix to the request. So it's not mandatory + to write it. This information will be populated from the endpoint of rest frame. + """ + def __init__(self, app_instance): + self.instance = app_instance + if self.instance._auth_required: + self.token = self.instance.auth_ref.auth_token + self.auth_ref = self.instance.auth_ref + self.url = self.auth_ref.service_catalog.url_for(service_type="restfulapi", + service_name="restfulframework", + interface=self.instance.interface) + else: + if 'OS_REST_URL' in os.environ: + self.url = os.environ['OS_REST_URL'] + else: + raise Exception("OS_REST_URL environment variable missing") + + def get(self, url, data=None, params=None, decode_json=True): + return self._operation("get", url, data=data, params=params, decode_json=decode_json) + + def post(self, url, data=None, params=None, decode_json=True): + return self._operation("post", url, data=data, params=params, decode_json=decode_json) + + def put(self, url, data=None, params=None, decode_json=True): + return self._operation("put", url, data=data, params=params, decode_json=decode_json) + + def patch(self, url, data=None, params=None, decode_json=True): + return self._operation("patch", url, data=data, params=params, decode_json=decode_json) + + def delete(self, url, data=None, params=None, decode_json=True): + return self._operation("delete", url, data=data, params=params, decode_json=decode_json) + + def _operation(self, oper, url, data=None, params=None, decode_json=True): + + operation = getattr(requests, oper, None) + + if not operation: + raise NameError("Operation %s not found" % oper) + + if not url.startswith("http"): + url = self.url + '/' + url + + LOG.debug("Working with url %s" % url) + + # Disable request debug logs + logging.getLogger("requests").setLevel(logging.WARNING) + + # HACK:Check if the authentication will expire and if so then renew it + if self.instance._auth_required and self.auth_ref.will_expire_soon(): + LOG.debug("Session will expire soon... Renewing token") + self.instance._auth_setup_completed = False + self.instance._auth_ref = None + self.token = self.instance.auth_ref.auth_token + else: + LOG.debug("Session is solid. Using existing token.") + + # Add security headers + arguments = {} + headers = {'User-Agent': 'HostCli'} + + if self.instance._auth_required: + headers.update({'X-Auth-Token': self.token}) + + if data: + if isinstance(data, dict): + arguments["json"] = data + headers["Content-Type"] = "application/json" + else: + arguments["data"] = data + headers["Content-Type"] = "text/plain" + + arguments["headers"] = headers + + if params: + arguments["params"] = params + + ret = operation(url, **arguments) + + if decode_json: + ret.raise_for_status() + try: + return ret.json() + except ValueError: + return {} + else: + return ret diff --git a/src/setup.py b/src/setup.py new file mode 100644 index 0000000..af972d1 --- /dev/null +++ b/src/setup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# Copyright 2019 Nokia +# +# 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. +# + + +PROJECT = 'hostcli' + +VERSION = '0.1' + +from setuptools import setup, find_packages + +setup( + name=PROJECT, + version=VERSION, + description='HOST CLI', + author='Janne Suominen', + author_email='janne.suominen@nokia.com', + platforms=['Any'], + scripts=[], + provides=[], + install_requires=['cliff', 'requests', 'keystoneauth1', 'osc_lib'], + packages=find_packages(), + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'hostcli = hostcli.main:main' + ], + }, + zip_safe=False, +)