#1154 Add simple `mbs-cli` client tool.
Merged 3 months ago by jkaluza. Opened 4 months ago by jkaluza.
jkaluza/fm-orchestrator mbs-cli  into  master

file modified
+1

@@ -9,3 +9,4 @@ 

  recursive-include fedmsg.d *

  recursive-include tests *.yaml *.json

  recursive-include tests/scm_data *

+ recursive-include client *

file added
+300

@@ -0,0 +1,300 @@ 

+ #!/usr/bin/env python

+ # -*- coding: utf-8 -*-

+ # Copyright (c) 2019  Red Hat, Inc.

+ #

+ # Permission is hereby granted, free of charge, to any person obtaining a copy

+ # of this software and associated documentation files (the "Software"), to deal

+ # in the Software without restriction, including without limitation the rights

+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell

+ # copies of the Software, and to permit persons to whom the Software is

+ # furnished to do so, subject to the following conditions:

+ #

+ # The above copyright notice and this permission notice shall be included in all

+ # copies or substantial portions of the Software.

+ #

+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,

+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE

+ # SOFTWARE.

+ #

+ # Written by Chenxiong Qi <cqi@redhat.com>

+ #            Jan Kaluza <jkaluza@redhat.com>

+ 

+ from __future__ import print_function

+ import sys

+ import enum

+ from pprint import pprint

+ import json

+ import requests

+ import argparse

+ import sys

+ import openidc_client

+ import requests.exceptions

+ from six.moves import urllib_parse

+ from requests_kerberos import HTTPKerberosAuth

+ 

+ 

+ env_config = {

+     'fedora': {

+         'prod': {

+             'server_url': 'https://mbs.fedoraproject.org',

+         },

+         'staging': {

+             'server_url': 'https://mbs.stg.fedoraproject.org',

+         }

+     },

+     'redhat': {

+         'prod': {

+             'server_url': 'https://mbs.engineering.redhat.com',

+         },

+         'staging': {

+             'server_url': 'https://mbs.stage.engineering.redhat.com',

+         }

+     }

+ }

+ 

+ 

+ id_provider_config = {

+     'prod': 'https://id.fedoraproject.org/openidc/',

+     'staging': 'https://id.stg.fedoraproject.org/openidc/',

+ }

+ 

+ 

+ class AuthMech(enum.IntEnum):

+     OpenIDC = 1

+     Kerberos = 2

+     Anonymous = 3

+ 

+ 

+ class MBSCli(object):

+ 

+     def __init__(self, server_url, api_version='2', verify_ssl=True,

+                  auth_mech=None, openidc_token=None):

+         """Initialize MBS client

+ 

+         :param str server_url: Base server URL of MBS (For example "https://localhost.tld").

+         :param str api_version: API version client will call. Version 2 is the default.

+         :param bool verify_ssl: whether to verify SSL certificate over HTTP. By

+             default, always verify, but you are also always able to disable it

+             by passing False.

+         :param AuthMech auth_mech: specify what authentication mechanism is

+             used to interact with MBS server. Choose one mechanism from

+             AuthMech. Anonymous can be passed to force client not send

+             any authentication information. If this parameter is omitted,

+             same as Anonymous.

+         :param str openidc_token: token got from OpenIDC so that client can be

+             authenticated by MBS server. This is only required if

+             ``AuthMech.OpenIDC`` is passed to parameter ``auth_mech``.

+         """

+         self._server_url = server_url

+         self._api_version = api_version

+         self._verify_ssl = verify_ssl

+         if auth_mech == AuthMech.OpenIDC and not openidc_token:

+             raise ValueError('OpenIDC token must be specified when OpenIDC'

+                              ' authentication is enabled.')

+         self._openidc_token = openidc_token

+ 

+         if auth_mech is None:

+             self._auth_mech = AuthMech.Anonymous

+         else:

+             self._auth_mech = auth_mech

+ 

+     @classmethod

+     def get_auth_mech(cls, server_url):

+         """

+         Asks the MBS server running on `server_url` about the available

+         auth mechanisum and returns the AuthMech representing that mechanism.

+         """

+         cli = MBSCli(server_url, verify_ssl=False, auth_mech=AuthMech.Anonymous)

+         r = cli._get("about")

+         data = r.json()

+         if data["auth_method"] == "oidc":

+             return AuthMech.OpenIDC

+         elif data["auth_method"] == "kerberos":

+             return AuthMech.Kerberos

+         else:

+             raise ValueError("Unknown auth_method: %r".format(data["auth_method"]))

+ 

+     def _make_endpoint(self, resource_path):

+         """Helper method to construct URL to requested resource

+ 

+         URL of requested resource consists of the server URL, API version and

+         the resource path.

+ 

+         :param str resource_path: the part after API version representing

+             the concrete resource.

+         :return: the whole complete URL of requested resource.

+         :rtype: str

+         """

+         return urllib_parse.urljoin(

+             self._server_url,

+             'module-build-service/{0}/{1}'.format(self._api_version, resource_path.lstrip('/')))

+ 

+     def _make_request(self, method, resource_path, data=None):

+         """Make a HTTP request to server

+ 

+         :param str method: HTTP request method to send, GET, POST and DELETE

+             are supported.

+         :param str resource_path: path of requested resource.

+         :param dict data: corresponding data with specific request. It is

+             optional. None is default that means no data is send along with

+             request.

+         :return: requests Response object.

+         :rtype: requests.Response

+         :raises: if MBS does not response 200, exception will be raised

+             by ``requests.Response.raise_for_status``.

+         """

+         request_data = {}

+         headers = {}

+         if data:

+             if method in ('post', 'patch'):

+                 request_data['data'] = json.dumps(data)

+                 headers['Content-Type'] = 'application/json'

+             if method == 'get':

+                 request_data['params'] = data

+         if not self._verify_ssl:

+             request_data['verify'] = False

+         if self._auth_mech == AuthMech.OpenIDC:

+             headers['Authorization'] = 'Bearer {0}'.format(self._openidc_token)

+         elif self._auth_mech == AuthMech.Kerberos:

+             request_data['auth'] = HTTPKerberosAuth()

+ 

+         if headers:

+             request_data['headers'] = headers

+ 

+         request_method = getattr(requests, method)

+         resource_url = self._make_endpoint(resource_path)

+         r = request_method(resource_url, **request_data)

+ 

+         # Print error, for debugging

+         if r.status_code != 200:

+             print(r.text, file=sys.stderr)

+ 

+         r.raise_for_status()

+         return r

+ 

+     def _get(self, resource_path, data=None):

+         """Make a GET HTTP request to server"""

+         return self._make_request('get', resource_path, data)

+ 

+     def _post(self, resource_path, data=None):

+         """Make a POST HTTP request to server"""

+         return self._make_request('post', resource_path, data)

+ 

+     def _patch(self, resource_path, data=None):

+         """Make a PATCH HTTP request to server"""

+         return self._make_request('patch', resource_path, data)

+ 

+     def import_module(self, scmurl):

+         """

+         Imports the module defined by yaml file on SCM URL to MBS.

+         """

+         r = self._post("import-module/", {"scmurl": scmurl})

+         pprint(r.json())

+         return 0

+ 

+     def execute(self, args):

+         """

+         Executes the command based on the parsed arguments `args`.

+         """

+         if args.command == "import":

+             return self.import_module(args.scmurl)

+         return 1

+ 

+ 

+ def parse_args():

+     """

+     Parses command line arguments using argparse and returns the result.

+     """

+     parser = argparse.ArgumentParser(

+         description='''\

+     %(prog)s - MBS API client

+ 

+     If you have problems authenticating with OpenID Connect, try:

+ 

+     $ rm -rf ~/.openidc/

+     ''',

+         formatter_class=argparse.RawDescriptionHelpFormatter,

+     )

+     parser.add_argument(

+         '--redhat', action='store_const',

+         const='redhat', default='fedora', dest='infra',

+         help='Use internal MBS infra environment. If omitted, Fedora Infra will '

+              'be used by default.')

+     parser.add_argument(

+         '--staging', action='store_const',

+         const='staging', default='prod', dest='env',

+         help='Use Fedora Infra or internal staging environment, which depends on '

+              'if --redhat is specified. If omitted, production environment will '

+              'be used.')

+     parser.add_argument(

+         '--server', default=None, help="Use custom MBS server.")

+ 

+     subparsers = parser.add_subparsers(

+         description='Commands you can use in MBS client.')

+ 

+     import_parser = subparsers.add_parser(

+         'import', help='Import new virtual module.')

+     import_parser.set_defaults(command='import')

+     import_parser.add_argument(

+         'scmurl', default="",

+         help="SCM URL of module to import.")

+ 

+     args = parser.parse_args()

+ 

+     if not hasattr(args, "command"):

+         parser.print_help()

+         sys.exit(1)

+ 

+     return args

+ 

+ 

+ def create_mbs_client(args):

+     """

+     Creates the MBSCli instance according to parsed command line arguments

+     `args`.

+     """

+     if args.server is None:

+         mbs_url = env_config[args.infra][args.env]['server_url']

+     else:

+         mbs_url = args.server

+ 

+     auth_mech = MBSCli.get_auth_mech(mbs_url)

+     openidc_token=None

+ 

+     if auth_mech == AuthMech.OpenIDC:

+         id_provider = id_provider_config[args.env]

+ 

+         # Get the auth token using the OpenID client.

+         oidc = openidc_client.OpenIDCClient(

+             'mbs',

+             id_provider,

+             {'Token': 'Token', 'Authorization': 'Authorization'},

+             'mbs-authorizer',

+             'notsecret',

+         )

+ 

+         scopes = [

+             'openid',

+             'https://id.fedoraproject.org/scope/groups',

+             'https://mbs.fedoraproject.org/oidc/submit-build',

+         ]

+         try:

+             token = oidc.get_token(scopes, new_token=True)

+             token = oidc.report_token_issue()

+         except requests.exceptions.HTTPError as e:

+             print(e.response.text, file=sys.stderr)

+             raise

+ 

+         return MBSCli(mbs_url, auth_mech=auth_mech, openidc_token=token)

+ 

+     return MBSCli(mbs_url, auth_mech=auth_mech, openidc_token=openidc_token)

+ 

+ 

+ if __name__ == "__main__":

+     args = parse_args()

+     cli = create_mbs_client(args)

+     sys.exit(cli.execute(args))

file modified
+1

@@ -59,6 +59,7 @@ 

                'db = module_build_service.resolver.DBResolver:DBResolver',

            ],

        },

+       scripts=['client/mbs-cli'],

        data_files=[('/etc/module-build-service/', ['conf/cacert.pem',

                                                    'conf/config.py',

                                                    'conf/koji.conf',

The goal of this client is to allow simple access to endpoints we use
to manage MBS - like "import" module or "retire" module.

It is not intended to contain tasks available in pyrpkg.

So far only "import" module is implemented, because that's the only
command we have REST API for.

Maybe it's better to use enum as a parent?

Jan Kaluza?

I think so :)

What value are we getting from using property methods in this case?

An enum or a dictionary would probably be preferred since you wouldn't have to implement the has method.

ODCS? I also don't think we use 202 in MBS.

This should be printed out to stderr.

I don't think we use 202 in MBS

Optional: We don't have an API endpoint that accepts the "DELETE" method so this can be removed.

This should likely be something other than 0.

I'd avoid using a negative error code since they aren't conventional.

This should be printed out to stderr.

Optional: To make this easier to read, you can omit openidc_token=openidc_token since it will always be None here

rebased onto d5d64f8

3 months ago

I think I've addressed all the comments now.

Commit 93ce2da fixes this pull-request

Pull-Request has been merged by jkaluza

3 months ago

Pull-Request has been merged by jkaluza

3 months ago