From 93d2f6e36f7c6dab137d12fb92541a05d08af13b Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Thu, 16 Jun 2016 15:24:16 +0300 Subject: [PATCH 1/9] #16 Interactive mode --- piu/cli.py | 55 ++++++++++++++++++++++++++++++++------- piu/error_handling.py | 60 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 ++ 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 piu/error_handling.py diff --git a/piu/cli.py b/piu/cli.py index afe3322..9acedf8 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -5,18 +5,21 @@ import click import datetime +import operator import ipaddress import json import os import subprocess import requests +import boto3 import socket import sys import time import yaml import zign.api -from clickclick import error, AliasedGroup, print_table, OutputFormat +from clickclick import error, AliasedGroup, print_table, OutputFormat, choice +from .error_handling import handle_exceptions import piu @@ -177,9 +180,9 @@ def cli(ctx, config_file): @cli.command('request-access') -@click.argument('host', metavar='[USER]@HOST') -@click.argument('reason') -@click.argument('reason_cont', nargs=-1, metavar='[..]') +@click.argument('host', metavar='[USER]@HOST', required=False) +@click.argument('reason', required=False) +@click.argument('reason_cont', nargs=-1, metavar='[..]', required=False) @click.option('-U', '--user', help='Username to use for OAuth2 authentication', envvar='PIU_USER', metavar='NAME') @click.option('-p', '--password', help='Password to use for OAuth2 authentication', envvar='PIU_PASSWORD', metavar='PWD') @@ -187,14 +190,48 @@ def cli(ctx, config_file): @click.option('-O', '--odd-host', help='Odd SSH bastion hostname', envvar='ODD_HOST', metavar='HOSTNAME') @click.option('-t', '--lifetime', help='Lifetime of the SSH access request in minutes (default: 60)', type=click.IntRange(1, 525600, clamp=True)) +@click.option('--interactive', help='Offers assistance', envvar='PIU_INTERACTIVE', is_flag=True, default=False) @click.option('--insecure', help='Do not verify SSL certificate', is_flag=True, default=False) -@click.option('--clip', is_flag=True, help='Copy SSH command into clipboard', default=False) -@click.option('--connect', is_flag=True, help='Directly connect to the host', default=False) +@click.option('--clip', help='Copy SSH command into clipboard', is_flag=True, default=False) +@click.option('--connect', help='Directly connect to the host', envvar='PIU_CONNECT', is_flag=True, default=False) @click.pass_obj -def request_access(obj, host, user, password, even_url, odd_host, reason, reason_cont, insecure, lifetime, - clip, connect): +def request_access(obj, host, reason, reason_cont, user, password, even_url, odd_host, lifetime, interactive, + insecure, clip, connect): '''Request SSH access to a single host''' + if interactive: + ec2 = boto3.resource('ec2') + reservations = ec2.instances.filter( + Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) + stack_name = stack_version = None + instance_list = [] + for r in reservations: + tags = r.tags + if not tags: + continue + for d in tags: + d_k, d_v = d['Key'], d['Value'] + if d_k == 'StackName': + stack_name = d_v + elif d_k == 'StackVersion': + stack_version = d_v + if stack_name and stack_version: + instance_list.append({'stack_name': stack_name, 'stack_version': stack_version, + 'instance_id': r.instance_id, 'private_ip': r.private_ip_address}) + instance_count = len(instance_list) + sorted_instance_list = sorted(instance_list, key=operator.itemgetter('stack_name', 'stack_version')) + {d.update({'index': idx}) for idx, d in enumerate(sorted_instance_list, start=1)} + print_table('index stack_name stack_version private_ip instance_id'.split(), sorted_instance_list) + allowed_choices = ["{}".format(n) for n in range(1, instance_count + 1)] + instance_index = int(click.prompt('Choose an instance (1-{})'.format(instance_count), + type=click.Choice(allowed_choices))) - 1 + host = sorted_instance_list[instance_index]['private_ip'] + reason = click.prompt('Reason', default='Troubleshooting') + elif not host: + raise click.UsageError('Missing argument "host".') + elif not reason: + raise click.UsageError('Missing argument "reason".') + user = user or zign.api.get_config().get('user') or os.getenv('USER') parts = host.split('@') @@ -307,7 +344,7 @@ def list_access_requests(obj, user, odd_host, status, limit, offset, output): def main(): - cli() + handle_exceptions(cli)() if __name__ == '__main__': main() diff --git a/piu/error_handling.py b/piu/error_handling.py new file mode 100644 index 0000000..b81f760 --- /dev/null +++ b/piu/error_handling.py @@ -0,0 +1,60 @@ +import functools +import sys +from tempfile import NamedTemporaryFile +from traceback import format_exception + +from botocore.exceptions import ClientError, NoCredentialsError + + +def store_exception(exception: Exception) -> str: + """ + Stores the exception in a temporary file and returns its filename + """ + + tracebacks = format_exception(etype=type(exception), + value=exception, + tb=exception.__traceback__) # type: [str] + + content = ''.join(tracebacks) + + with NamedTemporaryFile(prefix="senza-traceback-", delete=False) as error_file: + file_name = error_file.name + error_file.write(content.encode()) + + return file_name + + +def is_credentials_expired_error(e: ClientError) -> bool: + return e.response['Error']['Code'] in ['ExpiredToken', 'RequestExpired'] + + +def handle_exceptions(func): + @functools.wraps(func) + def wrapper(): + try: + func() + except NoCredentialsError as e: + print('No AWS credentials found. Use the "mai" command-line tool to get a temporary access key\n' + 'or manually configure either ~/.aws/credentials or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.', + file=sys.stderr) + sys.exit(1) + except ClientError as e: + sys.stdout.flush() + if is_credentials_expired_error(e): + print('AWS credentials have expired.\n' + 'Use the "mai" command line tool to get a new temporary access key.', + file=sys.stderr) + sys.exit(1) + else: + file_name = store_exception(e) + print('Unknown Error.\n' + 'Please create an issue with the content of {fn}'.format(fn=file_name)) + sys.exit(1) + except Exception as e: + # Catch All + + file_name = store_exception(e) + print('Unknown Error.\n' + 'Please create an issue with the content of {fn}'.format(fn=file_name)) + sys.exit(1) + return wrapper diff --git a/requirements.txt b/requirements.txt index 62ae4f7..5eb75b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ PyYAML requests pyperclip stups-zign>=0.16 +boto3>=1.3.0 +botocore>=1.4.10 From 74084fb1850700053b485dd8bab896f625937dc1 Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Thu, 16 Jun 2016 15:30:41 +0300 Subject: [PATCH 2/9] #16 Removed unused import --- piu/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piu/cli.py b/piu/cli.py index 9acedf8..5be048d 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -18,7 +18,7 @@ import yaml import zign.api -from clickclick import error, AliasedGroup, print_table, OutputFormat, choice +from clickclick import error, AliasedGroup, print_table, OutputFormat from .error_handling import handle_exceptions import piu From 5cae539acc5971258e313305b26ccdd24783426c Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 14:02:40 +0300 Subject: [PATCH 3/9] #16 General improvements --- piu/cli.py | 68 +++++++++++++++++++++++++------------------ piu/error_handling.py | 2 +- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/piu/cli.py b/piu/cli.py index 5be048d..02996b1 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -200,36 +200,10 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd '''Request SSH access to a single host''' if interactive: - ec2 = boto3.resource('ec2') - reservations = ec2.instances.filter( - Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) - stack_name = stack_version = None - instance_list = [] - for r in reservations: - tags = r.tags - if not tags: - continue - for d in tags: - d_k, d_v = d['Key'], d['Value'] - if d_k == 'StackName': - stack_name = d_v - elif d_k == 'StackVersion': - stack_version = d_v - if stack_name and stack_version: - instance_list.append({'stack_name': stack_name, 'stack_version': stack_version, - 'instance_id': r.instance_id, 'private_ip': r.private_ip_address}) - instance_count = len(instance_list) - sorted_instance_list = sorted(instance_list, key=operator.itemgetter('stack_name', 'stack_version')) - {d.update({'index': idx}) for idx, d in enumerate(sorted_instance_list, start=1)} - print_table('index stack_name stack_version private_ip instance_id'.split(), sorted_instance_list) - allowed_choices = ["{}".format(n) for n in range(1, instance_count + 1)] - instance_index = int(click.prompt('Choose an instance (1-{})'.format(instance_count), - type=click.Choice(allowed_choices))) - 1 - host = sorted_instance_list[instance_index]['private_ip'] - reason = click.prompt('Reason', default='Troubleshooting') - elif not host: + host, reason = request_access_interactive() + if not host: raise click.UsageError('Missing argument "host".') - elif not reason: + if not reason: raise click.UsageError('Missing argument "reason".') user = user or zign.api.get_config().get('user') or os.getenv('USER') @@ -305,6 +279,42 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd sys.exit(return_code) +def request_access_interactive(): + region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or subprocess.getoutput('aws configure get region')) + ec2 = boto3.resource('ec2', region_name=region_name) + reservations = ec2.instances.filter( + Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) + name = stack_name = stack_version = None + instance_list = [] + for r in reservations: + tags = r.tags + if not tags: + continue + for d in tags: + d_k, d_v = d['Key'], d['Value'] + if d_k == 'Name': + name = d_v + elif d_k == 'StackName': + stack_name = d_v + elif d_k == 'StackVersion': + stack_version = d_v + if name and stack_name and stack_version: + instance_list.append({'name': name, 'stack_name': stack_name, 'stack_version': stack_version, + 'instance_id': r.instance_id, 'private_ip': r.private_ip_address}) + instance_count = len(instance_list) + sorted_instance_list = sorted(instance_list, key=operator.itemgetter('stack_name', 'stack_version')) + {d.update({'index': idx}) for idx, d in enumerate(sorted_instance_list, start=1)} + print() + print_table('index name stack_name stack_version private_ip instance_id'.split(), sorted_instance_list) + print() + allowed_choices = ["{}".format(n) for n in range(1, instance_count + 1)] + instance_index = int(click.prompt('Choose an instance (1-{})'.format(instance_count), + type=click.Choice(allowed_choices))) - 1 + host = sorted_instance_list[instance_index]['private_ip'] + reason = click.prompt('Reason', default='Troubleshooting') + return (host, reason) + + @cli.command('list-access-requests') @click.option('-u', '--user', help='Filter by username', metavar='NAME') @click.option('-O', '--odd-host', help='Odd SSH bastion hostname (default: my configured odd host)', diff --git a/piu/error_handling.py b/piu/error_handling.py index b81f760..62b42ab 100644 --- a/piu/error_handling.py +++ b/piu/error_handling.py @@ -17,7 +17,7 @@ def store_exception(exception: Exception) -> str: content = ''.join(tracebacks) - with NamedTemporaryFile(prefix="senza-traceback-", delete=False) as error_file: + with NamedTemporaryFile(prefix="piu-traceback-", delete=False) as error_file: file_name = error_file.name error_file.write(content.encode()) From 6d2ba296970b6dd8887e49961e9e64225ef461d9 Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 14:04:37 +0300 Subject: [PATCH 4/9] #16 Fixes PEP 8 E501 --- piu/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/piu/cli.py b/piu/cli.py index 02996b1..b9246dc 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -280,7 +280,8 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd def request_access_interactive(): - region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or subprocess.getoutput('aws configure get region')) + region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or + subprocess.getoutput('aws configure get region')) ec2 = boto3.resource('ec2', region_name=region_name) reservations = ec2.instances.filter( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) From 0bc80a29566b7543f6ee482aca789339ee3f0227 Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 14:07:38 +0300 Subject: [PATCH 5/9] #16 More PEP 8 fixes --- piu/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piu/cli.py b/piu/cli.py index b9246dc..cafb034 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -280,8 +280,8 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd def request_access_interactive(): - region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or - subprocess.getoutput('aws configure get region')) + region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or + subprocess.getoutput('aws configure get region')) ec2 = boto3.resource('ec2', region_name=region_name) reservations = ec2.instances.filter( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) From 4d291ac3e9ba43938b104ab4665980de997c426c Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 16:04:57 +0300 Subject: [PATCH 6/9] #16 Added tests --- piu/cli.py | 4 ++-- tests/test_cli.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/piu/cli.py b/piu/cli.py index cafb034..d050a1b 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -280,8 +280,8 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd def request_access_interactive(): - region_name = click.prompt('AWS region', default=os.getenv('PIU_REGION') or - subprocess.getoutput('aws configure get region')) + region_name = os.getenv('PIU_REGION') or click.prompt('AWS region', + default=subprocess.getoutput('aws configure get region')) ec2 = boto3.resource('ec2', region_name=region_name) reservations = ec2.instances.filter( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 684fc5d..8ab3d3d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -150,3 +150,24 @@ def mock__request_access(even_url, cacert, username, first_host, reason, with runner.isolated_filesystem(): result = runner.invoke(cli, ['request-access'], catch_exceptions=False) + + +def test_interactive_success(monkeypatch): + ec2 = MagicMock() + request_access = MagicMock() + + response = [] + response.append(MagicMock(**{'instance_id': 'i-123456', 'private_ip_address': '172.31.10.10', 'tags': [{'Key': 'Name', 'Value': 'stack1-0o1o0'}, {'Key': 'StackVersion', 'Value': '0o1o0'}, {'Key': 'StackName', 'Value': 'stack1'}]})) + response.append(MagicMock(**{'instance_id': 'i-789012', 'private_ip_address': '172.31.10.20', 'tags': [{'Key': 'Name', 'Value': 'stack2-0o1o0'}, {'Key': 'StackVersion', 'Value': '0o2o0'}, {'Key': 'StackName', 'Value': 'stack2'}]})) + ec2.instances.filter = MagicMock(return_value=response) + boto3 = MagicMock() + monkeypatch.setattr('boto3.resource', MagicMock(return_value=ec2)) + monkeypatch.setattr('piu.cli._request_access', MagicMock(side_effect=request_access)) + + runner = CliRunner() + input_stream = '\n'.join(['eu-west-1', '1', 'Troubleshooting']) + + with runner.isolated_filesystem(): + result = runner.invoke(cli, ['request-access', '--interactive'], input=input_stream, catch_exceptions=False) + + assert request_access.called From 6f11d58390af3bd69faba90807ad90a975822116 Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 16:07:18 +0300 Subject: [PATCH 7/9] #16 Yet more PEP 8 fixes --- piu/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/piu/cli.py b/piu/cli.py index d050a1b..2b35045 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -280,8 +280,9 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd def request_access_interactive(): - region_name = os.getenv('PIU_REGION') or click.prompt('AWS region', - default=subprocess.getoutput('aws configure get region')) + region_name = os.getenv('PIU_REGION') or \ + click.prompt('AWS region', + default=subprocess.getoutput('aws configure get region')) ec2 = boto3.resource('ec2', region_name=region_name) reservations = ec2.instances.filter( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) From 677ea5b2e6cadc135887b370417403105e5d6d51 Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Fri, 17 Jun 2016 16:45:44 +0300 Subject: [PATCH 8/9] #16 Make Travis CI work? --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ab3d3d..485f4b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -165,7 +165,7 @@ def test_interactive_success(monkeypatch): monkeypatch.setattr('piu.cli._request_access', MagicMock(side_effect=request_access)) runner = CliRunner() - input_stream = '\n'.join(['eu-west-1', '1', 'Troubleshooting']) + input_stream = '\n'.join(['eu-west-1', '1', 'Troubleshooting']) + '\n' with runner.isolated_filesystem(): result = runner.invoke(cli, ['request-access', '--interactive'], input=input_stream, catch_exceptions=False) From ea072d913776034be9ba07f7856851f6604b2dfa Mon Sep 17 00:00:00 2001 From: Ruben Diaz Date: Mon, 20 Jun 2016 13:06:04 +0300 Subject: [PATCH 9/9] #16 Better AWS region defaults --- piu/cli.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/piu/cli.py b/piu/cli.py index 2b35045..d1fcaee 100644 --- a/piu/cli.py +++ b/piu/cli.py @@ -6,6 +6,7 @@ import click import datetime import operator +import configparser import ipaddress import json import os @@ -279,11 +280,24 @@ def request_access(obj, host, reason, reason_cont, user, password, even_url, odd sys.exit(return_code) +def get_region(): + aws_default_region_envvar = os.getenv('AWS_DEFAULT_REGION') + if aws_default_region_envvar: + return aws_default_region_envvar + + config = configparser.ConfigParser() + try: + config.read(os.path.expanduser('~/.aws/config')) + if 'default' in config: + region = config['default']['region'] + return region + except: + return '' + + def request_access_interactive(): - region_name = os.getenv('PIU_REGION') or \ - click.prompt('AWS region', - default=subprocess.getoutput('aws configure get region')) - ec2 = boto3.resource('ec2', region_name=region_name) + region = click.prompt('AWS region', default=get_region()) + ec2 = boto3.resource('ec2', region_name=region) reservations = ec2.instances.filter( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) name = stack_name = stack_version = None