diff options
Diffstat (limited to 'contrib/python/cmd/pydman.py')
-rwxr-xr-x | contrib/python/cmd/pydman.py | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/contrib/python/cmd/pydman.py b/contrib/python/cmd/pydman.py new file mode 100755 index 000000000..5008c706d --- /dev/null +++ b/contrib/python/cmd/pydman.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Remote podman client.""" + +import argparse +import curses +import getpass +import inspect +import logging +import os +import sys + +import pkg_resources + +import lib.actions +import pytoml + +assert lib.actions # silence pyflakes + +# TODO: setup.py and obtain __version__ from rpm.spec +try: + __version__ = pkg_resources.get_distribution('pydman').version +except Exception: + __version__ = '0.0.0' + + +class HelpFormatter(argparse.RawDescriptionHelpFormatter): + """Set help width to screen size.""" + + def __init__(self, *args, **kwargs): + """Construct HelpFormatter using screen width.""" + if 'width' not in kwargs: + kwargs['width'] = 80 + try: + height, width = curses.initscr().getmaxyx() + kwargs['width'] = width + finally: + curses.endwin() + super().__init__(*args, **kwargs) + + +class PodmanArgumentParser(argparse.ArgumentParser): + """Default remote podman configuration.""" + + def __init__(self, **kwargs): + """Construct the parser.""" + kwargs['add_help'] = True + kwargs['allow_abbrev'] = True + kwargs['description'] = __doc__ + kwargs['formatter_class'] = HelpFormatter + + super().__init__(**kwargs) + + def initialize_parser(self): + """Initialize parser without causing recursion meltdown.""" + self.add_argument( + '--version', + action='version', + version='%(prog)s v. ' + __version__) + self.add_argument( + '--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default='INFO', + type=str.upper, + help='set logging level for events. (default: %(default)s)', + ) + self.add_argument( + '--run-dir', + help=('directory to place local socket bindings.' + ' (default: XDG_RUNTIME_DIR)')) + self.add_argument( + '--user', + help=('Authenicating user on remote host.' + ' (default: {})').format(getpass.getuser())) + self.add_argument( + '--host', help='name of remote host. (default: None)') + self.add_argument( + '--remote-socket-path', + help=('path of podman socket on remote host' + ' (default: /run/podman/io.projectatomic.podman)')) + self.add_argument( + '--identity-file', + help=('path to ssh identity file. (default: ~/.ssh/id_rsa)')) + self.add_argument( + '--config', + default='/etc/containers/podman_client.conf', + dest='config_file', + help='path of configuration file. (default: %(default)s)') + + actions_parser = self.add_subparsers( + dest='subparser_name', help='actions') + + for name, obj in inspect.getmembers( + sys.modules['lib.actions'], + lambda member: inspect.isclass(member)): + if hasattr(obj, 'subparser'): + try: + obj.subparser(actions_parser) + except NameError as e: + logging.critical(e) + logging.warning( + 'See subparser configuration for Class "{}"'.format( + name)) + sys.exit(3) + + def parse_args(self, args=None, namespace=None): + """Parse command line arguments, backed by env var and config_file.""" + self.initialize_parser() + cooked = super().parse_args(args, namespace) + return self.resolve_configuration(cooked) + + def resolve_configuration(self, args): + """Find and fill in any arguments not passed on command line.""" + try: + # Configuration file optionall, arguments may be provided elsewhere + with open(args.config_file, 'r') as stream: + config = pytoml.load(stream) + except OSError: + logging.info( + 'Failed to read: {}'.format(args.config_file), + exc_info=args.log_level == logging.DEBUG) + config = {'default': {}} + else: + if 'default' not in config: + config['default'] = {} + + def resolve(name, value): + if value: + setattr(args, name, value) + return value + self.error('Required argument "%s" is not configured.' % name) + + xdg = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'podman') \ + if os.environ.get('XDG_RUNTIME_DIR') else None + + resolve( + 'run_dir', + getattr(args, 'run_dir') + or os.environ.get('RUN_DIR') + or config['default'].get('run_dir') + or xdg + or '/tmp/podman' if os.path.isdir('/tmp') else None + ) # yapf: disable + + args.local_socket_path = os.path.join(args.run_dir, "podman.socket") + + resolve( + 'host', + getattr(args, 'host') + or os.environ.get('HOST') + or config['default'].get('host') + ) # yapf:disable + + resolve( + 'user', + getattr(args, 'user') + or os.environ.get('USER') + or config['default'].get('user') + or getpass.getuser() + ) # yapf:disable + + resolve( + 'remote_socket_path', + getattr(args, 'remote_socket_path') + or os.environ.get('REMOTE_SOCKET_PATH') + or config['default'].get('remote_socket_path') + or '/run/podman/io.projectatomic.podman' + ) # yapf:disable + + resolve( + 'identity_file', + getattr(args, 'identity_file') + or os.environ.get('IDENTITY_FILE') + or config['default'].get('identity_file') + or os.path.expanduser('~{}/.ssh/id_rsa'.format(args.user)) + ) # yapf:disable + + args.local_uri = "unix:{}".format(args.local_socket_path) + args.remote_uri = "ssh://{}@{}{}".format(args.user, args.host, + args.remote_socket_path) + return args + + def exit(self, status=0, message=None): + """Capture message and route to logger.""" + if message: + log = logging.info if status == 0 else logging.error + log(message) + super().exit(status) + + def error(self, message): + """Capture message and route to logger.""" + logging.error('{}: {}'.format(self.prog, message)) + logging.error("Try '{} --help' for more information.".format( + self.prog)) + super().exit(2) + + +if __name__ == '__main__': + # Setup logging so we use stderr and can change logging level later + # Do it now before there is any chance of a default setup. + log = logging.getLogger() + fmt = logging.Formatter('%(asctime)s | %(levelname)-8s | %(message)s', + '%Y-%m-%d %H:%M:%S %Z') + stderr = logging.StreamHandler(stream=sys.stderr) + stderr.setFormatter(fmt) + log.addHandler(stderr) + log.setLevel(logging.INFO) + + parser = PodmanArgumentParser() + args = parser.parse_args() + + log.setLevel(args.log_level) + logging.debug('Logging initialized at level {}'.format( + logging.getLevelName(logging.getLogger().getEffectiveLevel()))) + + def istraceback(): + """Add traceback when logging events.""" + return log.getEffectiveLevel() == logging.DEBUG + + try: + if not os.path.exists(args.run_dir): + os.makedirs(args.run_dir) + except PermissionError as e: + logging.critical(e, exc_info=istraceback()) + sys.exit(6) + + # Klass(args).method() are setup by the sub-command's parser + returncode = None + try: + obj = args.klass(args) + except Exception as e: + logging.critical(repr(e), exc_info=istraceback()) + logging.warning('See subparser "{}" configuration.'.format( + args.subparser_name)) + sys.exit(5) + + try: + returncode = getattr(obj, args.method)() + except AttributeError as e: + logging.critical(e, exc_info=istraceback()) + logging.warning('See subparser "{}" configuration.'.format( + args.subparser_name)) + returncode = 3 + except (ConnectionResetError, TimeoutError) as e: + logging.critical(e, exc_info=istraceback()) + logging.info('Review connection arguments for correctness.') + returncode = 4 + + sys.exit(0 if returncode is None else returncode) |