# Copyright 2020 Red Hat, inc # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import argparse import configparser import logging import os import shutil import sys import tempfile import textwrap from zuulclient.api import ZuulRESTClient from zuulclient.utils import get_default from zuulclient.utils import encrypt_with_openssl from zuulclient.utils import formatters class ArgumentException(Exception): pass class ZuulClient(): app_name = 'zuul-client' app_description = 'Zuul User CLI' log = logging.getLogger("zuul-client") default_config_locations = ['~/.zuul.conf'] def __init__(self): self.args = None self.config = None def _get_version(self): from zuulclient.version import version_info return "Zuul-client version: %s" % version_info.release_string() def createParser(self): parser = argparse.ArgumentParser( description=self.app_description, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('-c', dest='config', help='specify the config file') parser.add_argument('--version', dest='version', action='version', version=self._get_version(), help='show zuul version') parser.add_argument('-v', dest='verbose', action='store_true', help='verbose output') parser.add_argument('--auth-token', dest='auth_token', required=False, default=None, help='Authentication Token, required by ' 'admin commands') parser.add_argument('--zuul-url', dest='zuul_url', required=False, default=None, help='Zuul base URL, needed if using the ' 'client without a configuration file') parser.add_argument('--use-config', dest='zuul_config', required=False, default=None, help='A predefined configuration in .zuul.conf') parser.add_argument('--insecure', dest='verify_ssl', required=False, action='store_false', help='Do not verify SSL connection to Zuul ' '(Defaults to False)') parser.add_argument('--format', choices=['JSON', 'text'], default='text', required=False, help='The output format, when applicable') self.createCommandParsers(parser) return parser def createCommandParsers(self, parser): subparsers = parser.add_subparsers(title='commands', description='valid commands', help='additional help') self.add_autohold_subparser(subparsers) self.add_autohold_delete_subparser(subparsers) self.add_autohold_info_subparser(subparsers) self.add_autohold_list_subparser(subparsers) self.add_enqueue_subparser(subparsers) self.add_enqueue_ref_subparser(subparsers) self.add_dequeue_subparser(subparsers) self.add_promote_subparser(subparsers) self.add_encrypt_subparser(subparsers) self.add_builds_list_subparser(subparsers) return subparsers def parseArguments(self, args=None): self.parser = self.createParser() self.args = self.parser.parse_args(args) if ( (self.args.zuul_url and self.args.zuul_config) or (not self.args.zuul_url and not self.args.zuul_config) ): raise ArgumentException( 'Either specify --zuul-url or use a config file') if not getattr(self.args, 'func', None): self.parser.print_help() sys.exit(1) if self.args.func == self.enqueue_ref: # if oldrev or newrev is set, ensure they're not the same if (self.args.oldrev is not None) or \ (self.args.newrev is not None): if self.args.oldrev == self.args.newrev: raise ArgumentException( "The old and new revisions must not be the same.") # if they're not set, we pad them out to zero if self.args.oldrev is None: self.args.oldrev = '0000000000000000000000000000000000000000' if self.args.newrev is None: self.args.newrev = '0000000000000000000000000000000000000000' if self.args.func == self.dequeue: if self.args.change is None and self.args.ref is None: raise ArgumentException("Change or ref needed.") if self.args.change is not None and self.args.ref is not None: raise ArgumentException( "The 'change' and 'ref' arguments are mutually exclusive.") @property def formatter(self): if self.args.format == 'JSON': return formatters.JSONFormatter elif self.args.format == 'text': return formatters.PrettyTableFormatter else: raise Exception('Unsupported formatter: %s' % self.args.format) def readConfig(self): safe_env = { k: v for k, v in os.environ.items() if k.startswith('ZUUL_') } self.config = configparser.ConfigParser(safe_env) if self.args.config: locations = [self.args.config] else: locations = self.default_config_locations for fp in locations: if os.path.exists(os.path.expanduser(fp)): self.config.read(os.path.expanduser(fp)) return raise ArgumentException( "Unable to locate config file in %s" % locations) def setup_logging(self): config_args = dict( format='%(levelname)-8s - %(message)s' ) if self.args.verbose: config_args['level'] = logging.DEBUG else: config_args['level'] = logging.ERROR # set logging across all components (urllib etc) logging.basicConfig(**config_args) if self.args.zuul_config and\ self.args.zuul_config in self.config.sections(): zuul_conf = self.args.zuul_config log_file = get_default(self.config, zuul_conf, 'log_file', None) if log_file is not None: fh = logging.FileHandler(log_file) f_loglevel = get_default(self.config, zuul_conf, 'log_level', 'INFO') fh.setLevel(getattr(logging, f_loglevel, 'INFO')) f_formatter = logging.Formatter( fmt='%(asctime)s %(name)s %(levelname)-8s - %(message)s', datefmt='%x %X' ) fh.setFormatter(f_formatter) self.log.addHandler(fh) def _main(self, args=None): # TODO make func return specific return codes try: self.parseArguments(args) if not self.args.zuul_url: self.readConfig() self.setup_logging() ret = self.args.func() except ArgumentException: if self.args.func: name = self.args.func.__name__ parser = getattr(self, 'cmd_' + name, self.parser) else: parser = self.parser parser.print_help() print() raise if ret: self.log.info('Command %s completed ' 'successfully' % self.args.func.__name__) return 0 else: self.log.error('Command %s completed ' 'with error(s)' % self.args.func.__name__) return 1 def main(self): try: sys.exit(self._main()) except Exception as e: self.log.exception( 'Failed with the following exception: %s ' % e ) sys.exit(1) def _check_tenant_scope(self, client): tenant_scope = client.info.get("tenant", None) tenant = self.tenant() if tenant != "": if tenant_scope is not None and tenant_scope != tenant: raise ArgumentException( "Error: Zuul API URL %s is " 'scoped to tenant "%s"' % (client.base_url, tenant_scope) ) else: if tenant_scope is None: raise ArgumentException( "Error: the --tenant argument or the 'tenant' " "field in the configuration file is required" ) def add_autohold_subparser(self, subparsers): cmd_autohold = subparsers.add_parser( 'autohold', help='hold nodes for failed job') cmd_autohold.add_argument('--tenant', help='tenant name', required=False, default='') cmd_autohold.add_argument('--project', help='project name', required=True) cmd_autohold.add_argument('--job', help='job name', required=True) cmd_autohold.add_argument('--change', help='specific change to hold nodes for', required=False, default='') cmd_autohold.add_argument('--ref', help='git ref to hold nodes for', required=False, default='') cmd_autohold.add_argument('--reason', help='reason for the hold', required=True) cmd_autohold.add_argument('--count', help='number of job runs (default: 1)', required=False, type=int, default=1) cmd_autohold.add_argument( '--node-hold-expiration', help=('how long in seconds should the node set be in HOLD status ' '(default: scheduler\'s default_hold_expiration value)'), required=False, type=int) cmd_autohold.set_defaults(func=self.autohold) self.cmd_autohold = cmd_autohold def autohold(self): if self.args.change and self.args.ref: raise Exception( "Change and ref can't be both used for the same request") if "," in self.args.change: raise Exception("Error: change argument can not contain any ','") node_hold_expiration = self.args.node_hold_expiration client = self.get_client() self._check_tenant_scope(client) kwargs = dict( tenant=self.tenant(), project=self.args.project, job=self.args.job, change=self.args.change, ref=self.args.ref, reason=self.args.reason, count=self.args.count, node_hold_expiration=node_hold_expiration) self.log.info('Invoking autohold with arguments: %s' % kwargs) r = client.autohold(**kwargs) return r def add_autohold_delete_subparser(self, subparsers): cmd_autohold_delete = subparsers.add_parser( 'autohold-delete', help='delete autohold request') cmd_autohold_delete.set_defaults(func=self.autohold_delete) cmd_autohold_delete.add_argument('--tenant', help='tenant name', required=False, default='') cmd_autohold_delete.add_argument('id', metavar='REQUEST_ID', help='the hold request ID') self.cmd_autohold_delete = cmd_autohold_delete def autohold_delete(self): client = self.get_client() self._check_tenant_scope(client) kwargs = dict( id=self.args.id, tenant=self.tenant() ) self.log.info('Invoking autohold-delete with arguments: %s' % kwargs) return client.autohold_delete(**kwargs) def add_autohold_info_subparser(self, subparsers): cmd_autohold_info = subparsers.add_parser( 'autohold-info', help='retrieve autohold request detailed info') cmd_autohold_info.set_defaults(func=self.autohold_info) cmd_autohold_info.add_argument('--tenant', help='tenant name', required=False, default='') cmd_autohold_info.add_argument('id', metavar='REQUEST_ID', help='the hold request ID') self.cmd_autohold_info = cmd_autohold_info def autohold_info(self): client = self.get_client() self._check_tenant_scope(client) request = client.autohold_info(self.args.id, self.tenant()) if not request: print("Autohold request not found") return False formatted_result = self.formatter('AutoholdQuery')(request) print(formatted_result) return True def add_autohold_list_subparser(self, subparsers): cmd_autohold_list = subparsers.add_parser( 'autohold-list', help='list autohold requests') cmd_autohold_list.add_argument('--tenant', help='tenant name', required=False, default='') cmd_autohold_list.set_defaults(func=self.autohold_list) self.cmd_autohold_list = cmd_autohold_list def autohold_list(self): client = self.get_client() self._check_tenant_scope(client) requests = client.autohold_list(tenant=self.tenant()) if not requests: print("No autohold requests found") return True formatted_result = self.formatter('AutoholdQueries')(requests) print(formatted_result) return True def add_enqueue_subparser(self, subparsers): cmd_enqueue = subparsers.add_parser('enqueue', help='enqueue a change') cmd_enqueue.add_argument('--tenant', help='tenant name', required=False, default='') cmd_enqueue.add_argument('--pipeline', help='pipeline name', required=True) cmd_enqueue.add_argument('--project', help='project name', required=True) cmd_enqueue.add_argument('--change', help='change id', required=True) cmd_enqueue.set_defaults(func=self.enqueue) self.cmd_enqueue = cmd_enqueue def enqueue(self): client = self.get_client() self._check_tenant_scope(client) kwargs = dict( tenant=self.tenant(), pipeline=self.args.pipeline, project=self.args.project, change=self.args.change ) self.log.info('Invoking enqueue with arguments: %s' % kwargs) r = client.enqueue(**kwargs) return r def add_enqueue_ref_subparser(self, subparsers): cmd_enqueue_ref = subparsers.add_parser( 'enqueue-ref', help='enqueue a ref', formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\ Submit a trigger event Directly enqueue a trigger event. This is usually used to manually "replay" a trigger received from an external source such as gerrit.''')) cmd_enqueue_ref.add_argument('--tenant', help='tenant name', required=False, default='') cmd_enqueue_ref.add_argument('--pipeline', help='pipeline name', required=True) cmd_enqueue_ref.add_argument('--project', help='project name', required=True) cmd_enqueue_ref.add_argument('--ref', help='ref name', required=True) cmd_enqueue_ref.add_argument( '--oldrev', help='old revision', default=None) cmd_enqueue_ref.add_argument( '--newrev', help='new revision', default=None) cmd_enqueue_ref.set_defaults(func=self.enqueue_ref) self.cmd_enqueue_ref = cmd_enqueue_ref def enqueue_ref(self): client = self.get_client() self._check_tenant_scope(client) kwargs = dict( tenant=self.tenant(), pipeline=self.args.pipeline, project=self.args.project, ref=self.args.ref, oldrev=self.args.oldrev, newrev=self.args.newrev ) self.log.info('Invoking enqueue-ref with arguments: %s' % kwargs) r = client.enqueue_ref(**kwargs) return r def add_dequeue_subparser(self, subparsers): cmd_dequeue = subparsers.add_parser('dequeue', help='dequeue a buildset by its ' 'change or ref') cmd_dequeue.add_argument('--tenant', help='tenant name', required=False, default='') cmd_dequeue.add_argument('--pipeline', help='pipeline name', required=True) cmd_dequeue.add_argument('--project', help='project name', required=True) cmd_dequeue.add_argument('--change', help='change id', default=None) cmd_dequeue.add_argument('--ref', help='ref name', default=None) cmd_dequeue.set_defaults(func=self.dequeue) self.cmd_dequeue = cmd_dequeue def dequeue(self): client = self.get_client() self._check_tenant_scope(client) kwargs = dict( tenant=self.tenant(), pipeline=self.args.pipeline, project=self.args.project, change=self.args.change, ref=self.args.ref ) self.log.info('Invoking dequeue with arguments: %s' % kwargs) r = client.dequeue(**kwargs) return r def add_promote_subparser(self, subparsers): cmd_promote = subparsers.add_parser('promote', help='promote one or more changes') cmd_promote.add_argument('--tenant', help='tenant name', required=False, default='') cmd_promote.add_argument('--pipeline', help='pipeline name', required=True) cmd_promote.add_argument('--changes', help='change ids', required=True, nargs='+') cmd_promote.set_defaults(func=self.promote) self.cmd_promote = cmd_promote def promote(self): client = self.get_client() self._check_tenant_scope(client) kwargs = dict( tenant=self.tenant(), pipeline=self.args.pipeline, change_ids=self.args.changes ) self.log.info('Invoking promote with arguments: %s' % kwargs) r = client.promote(**kwargs) return r def get_client(self): if self.args.zuul_url: self.log.debug( 'Using Zuul URL provided as argument to instantiate client') client = ZuulRESTClient(self.args.zuul_url, self.args.verify_ssl, self.args.auth_token) return client conf_sections = self.config.sections() if len(conf_sections) == 1 and self.args.zuul_config is None: zuul_conf = conf_sections[0] self.log.debug( 'Using section "%s" found in ' 'config to instantiate client' % zuul_conf) elif self.args.zuul_config and self.args.zuul_config in conf_sections: zuul_conf = self.args.zuul_config else: raise Exception('Unable to find a way to connect to Zuul, ' 'provide the "--zuul-url" argument or set up a ' '.zuul.conf file.') server = get_default(self.config, zuul_conf, 'url', None) verify = get_default(self.config, zuul_conf, 'verify_ssl', self.args.verify_ssl) # Allow token override by CLI argument auth_token = self.args.auth_token or get_default(self.config, zuul_conf, 'auth_token', None) if server is None: raise Exception('Missing "url" configuration value') client = ZuulRESTClient(server, verify, auth_token) return client def tenant(self): if self.args.tenant == "": if self.config is not None: config_tenant = "" conf_sections = self.config.sections() if ( self.args.zuul_config and self.args.zuul_config in conf_sections ): zuul_conf = self.args.zuul_config config_tenant = get_default( self.config, zuul_conf, "tenant", "" ) return config_tenant return self.args.tenant def add_encrypt_subparser(self, subparsers): cmd_encrypt = subparsers.add_parser( 'encrypt', help='Encrypt a secret to be used in a project\'s jobs') cmd_encrypt.add_argument('--public-key', help='path to project public key ' '(bypass API call)', metavar='/path/to/pubkey', required=False, default=None) cmd_encrypt.add_argument('--tenant', help='tenant name', required=False, default='') cmd_encrypt.add_argument('--project', help='project name', required=False, default=None) cmd_encrypt.add_argument('--no-strip', action='store_true', help='Do not strip whitespace from beginning ' 'or end of input. Ignored when ' '--infile is used.', default=False) cmd_encrypt.add_argument('--secret-name', default=None, help='How the secret should be named. If not ' 'supplied, a placeholder will be used.') cmd_encrypt.add_argument('--field-name', default=None, help='How the name of the secret variable. ' 'If not supplied, a placeholder will be ' 'used.') cmd_encrypt.add_argument('--infile', default=None, help='A filename whose contents will be ' 'encrypted. If not supplied, the value ' 'will be read from standard input.\n' 'If entering the secret manually, press ' 'Ctrl+d when finished to process the ' 'secret.') cmd_encrypt.add_argument('--outfile', default=None, help='A filename to which the encrypted ' 'value will be written. If not ' 'supplied, the value will be written ' 'to standard output.') cmd_encrypt.set_defaults(func=self.encrypt) self.cmd_encrypt = cmd_encrypt def encrypt(self): if self.args.project is None and self.args.public_key is None: raise ArgumentException( 'Either provide a public key or a project to continue' ) strip = not self.args.no_strip if self.args.infile: strip = False try: with open(self.args.infile) as f: plaintext = f.read() except FileNotFoundError: raise Exception('File "%s" not found' % self.args.infile) except PermissionError: raise Exception( 'Insufficient rights to open %s' % self.args.infile) else: plaintext = sys.stdin.read() if strip: plaintext = plaintext.strip() pubkey_file = tempfile.NamedTemporaryFile(delete=False) self.log.debug('Creating temporary key file %s' % pubkey_file.name) try: if self.args.public_key is not None: self.log.debug('Using local public key') shutil.copy(self.args.public_key, pubkey_file.name) else: client = self.get_client() self._check_tenant_scope(client) key = client.get_key(self.tenant(), self.args.project) pubkey_file.write(str.encode(key)) pubkey_file.close() self.log.debug('Invoking openssl') ciphertext_chunks = encrypt_with_openssl(pubkey_file.name, plaintext, self.log) output = textwrap.dedent( ''' - secret: name: {} data: {}: !encrypted/pkcs1-oaep '''.format(self.args.secret_name or '', self.args.field_name or '')) twrap = textwrap.TextWrapper(width=79, initial_indent=' ' * 8, subsequent_indent=' ' * 10) for chunk in ciphertext_chunks: chunk = twrap.fill('- ' + chunk) output += chunk + '\n' if self.args.outfile: with open(self.args.outfile, "w") as f: f.write(output) else: print(output) return_code = True except ArgumentException as e: # do not log and re-raise, caught later raise e except Exception as e: self.log.exception(e) return_code = False finally: self.log.debug('Deleting temporary key file %s' % pubkey_file.name) os.unlink(pubkey_file.name) return return_code def add_builds_list_subparser(self, subparsers): cmd_builds = subparsers.add_parser( 'builds', help='List builds matching search criteria') cmd_builds.add_argument( '--tenant', help='tenant name', required=True) cmd_builds.add_argument( '--project', help='project name') cmd_builds.add_argument( '--pipeline', help='pipeline name') cmd_builds.add_argument( '--change', help='change reference') cmd_builds.add_argument( '--branch', help='branch name') cmd_builds.add_argument( '--patchset', help='patchset number') cmd_builds.add_argument( '--ref', help='ref name') cmd_builds.add_argument( '--newrev', help='the applied revision') cmd_builds.add_argument( '--job', help='job name') cmd_builds.add_argument( '--voting', help='show voting builds only', action='store_true', default=False) cmd_builds.add_argument( '--non-voting', help='show non-voting builds only', action='store_true', default=False) cmd_builds.add_argument( '--node', help='node name') cmd_builds.add_argument( '--result', help='build result') cmd_builds.add_argument( '--final', help='show final builds only', action='store_true', default=False) cmd_builds.add_argument( '--held', help='show held builds only', action='store_true', default=False) cmd_builds.add_argument( '--limit', help='maximum amount of results to return', default=50, type=int) cmd_builds.add_argument( '--skip', help='how many results to skip', default=0, type=int) cmd_builds.set_defaults(func=self.builds) def builds(self): if self.args.voting and self.args.non_voting: raise Exception('--voting and --non-voting are mutually exclusive') filters = {'limit': self.args.limit, 'skip': self.args.skip} if self.args.project: filters['project'] = self.args.project if self.args.pipeline: filters['pipeline'] = self.args.pipeline if self.args.change: filters['change'] = self.args.change if self.args.branch: filters['branch'] = self.args.branch if self.args.patchset: filters['patchset'] = self.args.patchset if self.args.ref: filters['ref'] = self.args.ref if self.args.newrev: filters['newrev'] = self.args.newrev if self.args.job: filters['job_name'] = self.args.job if self.args.voting: filters['voting'] = True if self.args.non_voting: filters['voting'] = False if self.args.node: filters['node'] = self.args.node if self.args.result: filters['result'] = self.args.result if self.args.final: filters['final'] = True if self.args.held: filters['held'] = True client = self.get_client() request = client.builds(tenant=self.tenant(), **filters) formatted_result = self.formatter('Builds')(request) print(formatted_result) return True def main(): ZuulClient().main()