Source code for kubey.cli

import os
import sys
import logging
import re
import signal
import click

from configstruct import OpenStruct
from collections import defaultdict

from . import tabular

from .kubey import Kubey
from .event import Event
from .node import Node
from .pod import Pod


[docs]class ColumnsOption(click.ParamType): name = 'columns' envvar_list_splitter = ',' def __init__(self, cls): self._cls = cls self.default = self._join(self._cls.PRIMARY_ATTRIBUTES) self.help = 'specify one or more of ALL,{0} [default: {1}]'.format( self._join(self._cls.ATTRIBUTES), self.default)
[docs] def convert(self, value, param, ctx): if value == 'ALL': return self._cls.ATTRIBUTES columns = self.split_envvar_value(value) if 'DEF' in columns: i = columns.index('DEF') del(columns[i]) for c in reversed(self._cls.PRIMARY_ATTRIBUTES): columns.insert(i, c) unknown = set(columns) - (set(columns) & set(self._cls.ATTRIBUTES)) if len(unknown) > 0: self.fail('unknown columns: ' + ','.join(unknown)) return columns
def _join(self, attrs): return self.envvar_list_splitter.join(attrs)
_logger = None _event_columns = ColumnsOption(Event) _node_columns = ColumnsOption(Node) _pod_columns = ColumnsOption(Pod) @click.group(invoke_without_command=True, context_settings=dict(help_option_names=['-h', '--help'])) @click.version_option() @click.option('--cache-seconds', envvar='KUBEY_CACHE_SECONDS', default=300, show_default=True, help='change number of seconds to keep pod info cached') @click.option('-l', '--log-level', envvar='KUBEY_LOG_LEVEL', type=click.Choice(('debug', 'info', 'warning', 'error', 'critical')), default='info', help='set logging level') @click.option('-c', '--context', envvar='KUBEY_CONTEXT', help='context to use when selecting') @click.option('-n', '--namespace', envvar='KUBEY_NAMESPACE', default='production', show_default=True, help='namespace to use when selecting') @click.option('-f', '--format', 'table_format', envvar='KUBEY_TABLE_FORMAT', type=click.Choice(tabular.formats), default='simple', show_default=True, help='output format of tabular data (e.g. listing)') @click.option('-m', '--max', 'maximum', type=int, help='max number of matches') @click.option('--no-headers', is_flag=True, help='disable table headers') @click.option('--wide', is_flag=True, help='force use of wide output') @click.argument('match') @click.pass_context def cli(ctx, cache_seconds, log_level, context, namespace, table_format, maximum, no_headers, wide, match): '''Simple wrapper to help find specific Kubernetes pods and containers and run asynchronous commands (default is to list those that matched). \b MATCH [<NODE>/]<POD>[/<CONTAINER>] \b NODE provide a regular expression to select one or more nodes POD provide a regular expression to select one or more pods CONTAINER provide a regular expression to select one or more containers \b Partial match using just node or just node and pod, provide trailing slash: my-node// match all pods and containers hosted in my-node my-node/my-pod/ match all containers hosted in my-node/my-pod ''' logging.basicConfig( level=getattr(logging, log_level.upper()), format='[%(asctime)s #%(process)d] %(levelname)-8s %(name)-12s %(message)s', datefmt='%Y-%m-%dT%H:%M:%S%z' ) global _logger _logger = logging.getLogger(__name__) if not wide: width, height = click.get_terminal_size() wide = width > 160 highlight = sys.stdout.isatty() def highlight_with(color): if not highlight: return str def colorizer(obj): return click.style(str(obj), bold=True, fg=color) return colorizer hard_percent_limit = 80 # TODO: consider making cfg'abl soft_percent_limit = hard_percent_limit * (hard_percent_limit / 100.0) ctx.obj = OpenStruct( highlight_ok=highlight_with('green'), highlight_warn=highlight_with('yellow'), highlight_error=highlight_with('red'), hard_percent_limit=hard_percent_limit, soft_percent_limit=soft_percent_limit, cache_path=os.path.expanduser('~'), cache_seconds=cache_seconds, context=context, namespace=namespace, table_format=table_format, no_headers=no_headers, wide=wide, maximum=maximum, match=match, ) ctx.obj.kubey = Kubey(ctx.obj) def handle_interrupt(signal, _frame): ctx.obj.kubey.kubectl.kill(signal) ctx.exit(22) signal.signal(signal.SIGINT, handle_interrupt) signal.signal(signal.SIGTERM, handle_interrupt) if not ctx.invoked_subcommand: ctx.invoke(list_pods) @cli.command() @click.option('-c', '--columns', type=_node_columns, default=_node_columns.default, help=_node_columns.help) @click.option('-f', '--flat', is_flag=True, help='flatten columns with multiple items') @click.pass_obj def health(obj, columns, flat): '''Show health stats about matches.''' click.echo(tabular.tabulate(obj, obj.kubey.each_node(obj.maximum, True), columns, flat)) # FIXME: if --wide use all attributes, not default @cli.command(name='list') @click.option('-c', '--columns', type=_pod_columns, default=_pod_columns.default, help=_pod_columns.help) @click.option('-f', '--flat', is_flag=True, help='flatten columns with multiple items') @click.pass_obj def list_pods(obj, columns, flat): '''List available pods and containers for current context.''' # FIXME: find a "click" way to ask if columns were provided or defaults used if obj.namespace == Kubey.ANY and '-c' not in sys.argv and '--columns' not in sys.argv: columns = ['namespace'] + columns click.echo(tabular.tabulate(obj, obj.kubey.each_pod(obj.maximum), columns, flat)) @cli.command() @click.pass_obj def webui(obj): '''List dashboard links for matching pods (if only one matched, URL is opened in browser).''' kubectl = obj.kubey.kubectl info = click.unstyle(kubectl.call_capture('cluster-info')) dash_endpoint = re.search(r'kubernetes-dashboard.*?(http\S+)', info).group(1) urls = [] for pod in obj.kubey.each_pod(obj.maximum): pod_path = '/#/pod/{0}/{1}?namespace={0}'.format(pod.namespace, pod.name) urls.append(dash_endpoint + pod_path) if len(urls) == 1: url = urls[0] click.echo(url) click.launch(url) else: for url in urls: click.echo(url) @cli.command() @click.argument('repl') @click.argument('arguments', nargs=-1, type=click.UNPROCESSED) @click.pass_context def repl(ctx, repl, arguments): '''Start an interactive Read-Eval-Print Loop (REPL), e.g. bash, rails console, etc.''' ctx.invoke(each, interactive=True, command=repl, arguments=arguments) @cli.command(context_settings=dict(ignore_unknown_options=True)) @click.option('-s', '--shell', default='/bin/sh', show_default=True, help='alternate shell used for remote execution') @click.option('-i', '--interactive', is_flag=True, help='require interactive session ' '(works with REPLs like shells or other command instances needing input)') @click.option('-a', '--async', is_flag=True, help='run commands asynchronously (incompatible with "interactive")') @click.option('-p', '--prefix', is_flag=True, help='add a prefix to all output indicating the pod and container names ' '(incompatible with "interactive")') @click.argument('command') @click.argument('arguments', nargs=-1, type=click.UNPROCESSED) @click.pass_obj def each(obj, shell, interactive, async, prefix, command, arguments): '''Execute a command remotely for each pod matched.''' kubectl = obj.kubey.kubectl kexec_args = ['exec'] if interactive: kexec_args.append('-ti') # FIXME: when https://github.com/docker/docker/issues/8755 is fixed, remove env/term? # for now, this allows for "fancy" terminal apps run in interactive mode arguments = ('TERM=xterm', command) + arguments command = 'env' remote_args = [command] + [quote(a) for a in arguments] # TODO: consider using "sh -c exec ..." only if command has no semicolon? remote_cmd = [shell, '-c', ' '.join(remote_args)] # TODO: add option to include 'node' name in prefix for pod in obj.kubey.each_pod(obj.maximum): for container in pod.containers: if not container.ready: _logger.warn('skipping ' + str(container)) continue args = kexec_args + \ ['-n', pod.namespace, '-c', container.name, pod.name, '--'] + \ remote_cmd if prefix: args.insert(0, '[%s/%s] ' % (pod.name, container.name)) kubectl.call_prefix(*args) else: kubectl.call_async(*args) if not async: kubectl.wait() if async: kubectl.wait() if kubectl.final_rc != 0: click.get_current_context().exit(kubectl.final_rc) @cli.command(name='ctl-each', context_settings=dict(ignore_unknown_options=True)) @click.argument('command') @click.argument('arguments', nargs=-1, type=click.UNPROCESSED) @click.pass_obj def ctl_each(obj, command, arguments): '''Invoke any kubectl command directly for each pod matched and collate the output.''' width, height = click.get_terminal_size() kubectl = obj.kubey.kubectl collector = tabular.RowCollector() ns_pods = defaultdict(list) for pod in obj.kubey.each_pod(obj.maximum): ns_pods[pod.namespace].append(pod) for ns, pods in ns_pods.items(): args = ['-n', ns] + list(arguments) + [p.name for p in pods] kubectl.call_table_rows(collector.handler_for(ns), command, *args) kubectl.wait() if collector.rows: click.echo(tabular.tabulate(obj, sorted(collector.rows), collector.headers)) if kubectl.final_rc != 0: click.get_current_context().exit(kubectl.final_rc) @cli.command() @click.option('-f', '--follow', is_flag=True, help='stream new logs until interrupted') @click.option('-p', '--prefix', is_flag=True, help='add a prefix to all output indicating the pod and container names') @click.argument('number', default='10') @click.pass_obj def tail(obj, follow, prefix, number): '''Show recent logs from containers for each pod matched. NUMBER is a count of recent lines or a relative duration (e.g. 5s, 2m, 3h) ''' kubectl = obj.kubey.kubectl if re.match(r'^\d+$', number): log_args = ['--tail', str(number)] else: log_args = ['--since', number] if follow: log_args.append('-f') for pod in obj.kubey.each_pod(obj.maximum): for container in pod.containers: args = ['-n', pod.namespace, '-c', container.name] + log_args + [pod.name] if prefix: prefix = '[%s:%s] ' % (pod.name, container.name) kubectl.call_prefix(prefix, 'logs', *args) else: kubectl.call_async('logs', *args) kubectl.wait() if kubectl.final_rc != 0: click.get_current_context().exit(kubectl.final_rc) @cli.command() @click.option('-c', '--columns', type=_event_columns, default=_event_columns.default, help=_event_columns.help) @click.pass_obj def events(obj, columns): '''Show events associated with matched nodes, pods, and/or containers.''' if obj.namespace == Kubey.ANY and '-c' not in sys.argv and '--columns' not in sys.argv: columns = ['namespace'] + columns for line in tabular.lines(obj, obj.kubey.each_event(obj.maximum), columns): click.echo(line) # not using shlex/pipes.quote because we want glob expansion for remote calls
[docs]def quote(arg): if ' ' not in arg or re.match(r'^[\'"].*[\'"]$', arg): return arg if "'" in arg: if '"' in arg: raise ValueError('Unable to quote: ' + arg) return '"' + arg + '"' return "'" + arg + "'"
########################## if __name__ == '__main__': cli()