Seed code for hostcli 38/638/1 master
authorjanne.suominen <janne.suominen@nokia.com>
Wed, 8 May 2019 12:02:46 +0000 (15:02 +0300)
committerjanne.suominen <janne.suominen@nokia.com>
Wed, 8 May 2019 12:03:30 +0000 (15:03 +0300)
Seed code for hostcli

Change-Id: I964d71697266410830d99122f64b6ff1511c0b40
Signed-off-by: janne.suominen <janne.suominen@nokia.com>
LICENSE [new file with mode: 0644]
README.rst [new file with mode: 0644]
docs/hostcli.asciio [new file with mode: 0644]
docs/hostcli.png [new file with mode: 0644]
hostcli.spec [new file with mode: 0644]
src/hostcli/__init__.py [new file with mode: 0644]
src/hostcli/helper.py [new file with mode: 0644]
src/hostcli/main.py [new file with mode: 0644]
src/hostcli/resthandler.py [new file with mode: 0644]
src/setup.py [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
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 (file)
index 0000000..93fa916
--- /dev/null
@@ -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 <openstack domain> <operation> [<options>]
+
+
+Below is an example of some **openstack** commands.
+
+::
+
+  openstack server list
+
+  openstack server show <server-name>
+
+  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 <middleware domain> <operation> [<options>]
+
+Below is an example of some **hostcli** commands.
+
+::
+
+ hostcli has show nodes
+
+ hostcli has show services --node <node-name>
+
+ hostcli motd show
+
+ hostcli motd set --motd <message of the day text>
+
+ ...
+
+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 (file)
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 (file)
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 (file)
index 0000000..1f81310
--- /dev/null
@@ -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 (file)
index 0000000..f035b4a
--- /dev/null
@@ -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 (file)
index 0000000..9f569c4
--- /dev/null
@@ -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 (file)
index 0000000..ba5bbd8
--- /dev/null
@@ -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='<identity-api-version>',
+                            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 (file)
index 0000000..0f1d16d
--- /dev/null
@@ -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 (file)
index 0000000..af972d1
--- /dev/null
@@ -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,
+)