diff --git a/README.md b/README.md index 6e9aecc..cb35045 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # spotinst-cli spotinst-cli is is an interactive command line tool which allows you to to control your spotinst groups and instances. +### Installation +``` +pip install spotinstcli +``` + ### Usage spotinst-cli has the following flags - @@ -64,13 +69,4 @@ Set variables like: SpotInst account ids can be found in the web console under settings/account for each of your accounts. There is currently no way to pull account ids from the SpotInst API. -### Installation -``` -pip install spotinst-cli -``` -OR -``` -git clone git@github.com:giladsh1/spotinst-cli.git -pip install $(pwd)/requirements.txt --user --upgrade -ln -s /usr/local/bin/spotinst-cli $(pwd)spotinst-cli/spotinst-cli -``` + diff --git a/setup.py b/setup.py index 7a255d7..af59d5f 100644 --- a/setup.py +++ b/setup.py @@ -6,27 +6,36 @@ here = path.abspath(path.dirname(__file__)) # Get the long description from the README file -with open(path.join(here, "README.md"), encoding="utf-8") as f: +with open(path.join(here, 'README.md'), encoding='utf-8') as f: long_description = f.read() setup( - name="spotinst-cli", - version="0.1", - description="CLI tool for interacting with Spotinst AWS elasticgroups", + name='spotinstcli', + version='0.1.3', + description='CLI tool for interacting with Spotinst AWS elasticgroups', long_description=long_description, - url="https://github.com/giladsh1/spotinst-cli", - author="Gilad Sharaby", - author_email="giladsh1@gmail.com", + url='https://github.com/giladsh1/spotinst-cli', + author='Gilad Sharaby', + author_email='giladsh1@gmail.com', classifiers=[ - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', ], - keywords="spotinst cli aws", - scripts=["/usr/local/bin/spotinst-cli"], + keywords='spotinst cli aws spotinst-cli', + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'spotinst-cli = spotinstcli:main', + ] + }, install_requires = [ - "prettytable", - "requests" - ] + 'prettytable', + 'requests', + 'PyInquirer', + 'clint', + 'wcwidth' + ], + include_package_data=True ) \ No newline at end of file diff --git a/spotinst-cli b/spotinst-cli deleted file mode 100755 index 97a83fe..0000000 --- a/spotinst-cli +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env python - -# This tool allows the user to interact with spotinst API -# run the script with the -h flag for help - -from json import loads as json_loads, dumps as json_dumps -from collections import OrderedDict -from optparse import OptionParser -from os import system, environ -from sys import argv, stdout -from os import path -from base64 import b64encode, b64decode -from commands import getoutput, getstatusoutput -from threading import Thread -from pkg_resources import DistributionNotFound - - -# This function opens a thread with args -def open_thread(thread_target, thread_args): - t = Thread(target=thread_target, args = (thread_args)) - t.daemon = True - t.start() - thread_list.append(t) - - -# This function prints error and exits -def errorAndExit(exception_message): - print "\nERROR - %s\n" %(exception_message) - exit() - - -# This function changes the color of the prompt -def change_prompt_color(color): - colors = { - 'red': "\033[1;31m", - 'green': "\033[0;32m", - 'normal': "\033[0;0m" - } - stdout.write(colors.get(color)) - - -# This function prints a text in color and returns to normal -def print_in_color(color, msg): - change_prompt_color(color) - print msg - change_prompt_color('normal') - - -# This function prints a group name surrounded with = -def print_header(text): - text_len = len(text) - print "\n" + ("-" * text_len) - print text - print ("-" * text_len) - - -# This function queries spotinst API endpoint and returns the result to a queue -def query_thread(queue, group, api_url, spotinst_token, http_method='GET', req_payload=None, account=None): - try: - headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer %s' % spotinst_token, - } - if account is not None: - api_url = "{api_url}?accountId={account}".format(**locals()) - - result = "" - if http_method == 'GET': - result = get(api_url, headers=headers) - elif http_method == 'PUT': - result = put(api_url, headers=headers, data=req_payload) - elif http_method == 'POST': - result = post(api_url, headers=headers, data=req_payload) - elif http_method == 'DELETE': - result = delete(api_url, headers=headers, data=req_payload) - else: - errorAndExit("wrong http method: {http_method}".format(**locals())) - queue.append([http_method, result, group]) - except Exception as e: - errorAndExit(e.message) - - -# This function queries spotinst API endpoint -def query_api(api_url, spotinst_token, http_method='GET', req_payload=None, account=None): - try: - headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer %s' % spotinst_token, - } - if account is not None: - api_url = "{api_url}?accountId={account}".format(**locals()) - - if http_method == 'GET': - return get(api_url, headers=headers) - elif http_method == 'PUT': - return put(api_url, headers=headers, data=req_payload) - elif http_method == 'POST': - return post(api_url, headers=headers, data=req_payload) - elif http_method == 'DELETE': - return delete(api_url, headers=headers, data=req_payload) - else: - errorAndExit("wrong http method: {http_method}".format(**locals())) - except Exception as e: - errorAndExit(e.message) - - -# This function returns a groups dict, filtered by name (contains) or all if parameter is empty -def get_groups(base_api_path, token, account, grep_list, ungrep_list): - if not options.json: - print_message("Getting groups from Spotisnt, please wait...") - result = query_api(base_api_path, token, account=account) - if result.status_code > 299: - errorAndExit("bad response from Spotinst!\n{}".format(result.content)) - try: - data = json_loads(result.content) - except Exception as e: - errorAndExit("while loading json response: {e.message}".format(**locals())) - # apply grep and ungrep filters - groups = {} - all_groups = data['response']['items'] - if len(grep_list) == 0: - for group in all_groups: - groups[group['name']] = [group['id'], group['capacity']['minimum'], group['capacity']['target'], group['capacity']['maximum']] - else: - for group in all_groups: - group_name = group['name'].lower() - if [grep_term for grep_term in grep_list if grep_term.lower() in group_name] and not [ungrep_term for ungrep_term in ungrep_list if ungrep_term.lower() in group_name]: - groups[group['name']] = [group['id'], group['capacity']['minimum'], group['capacity']['target'], group['capacity']['maximum']] - # return a sorted list of groups - return OrderedDict(sorted(groups.items())) - - -# This function prints groups as a table -def print_all_groups(groups_to_print): - if not options.json: - table = PrettyTable(['#' ,'Group name', 'ID', 'Min', 'Target', 'Max']) - table.align = "l" - table.border = True - counter = 1 - for key, value in groups_to_print.iteritems(): - table.add_row([counter, key, value[0], value[1], value[2], value[3]]) - counter += 1 - print_message("Found the following groups: ") - print "{table}\n".format(**locals()) - else: - if options.list: - groups_to_print = { "groups" : groups_to_print.keys()} - print json_dumps(groups_to_print, indent=2) - - -# This function prints a message surrounded lines -def print_message(msg): - all_lines = msg.splitlines() - longest_line = 0 - for line in all_lines: - if len(line.strip()) > longest_line: - longest_line = len(line.strip()) - padding = 8 - print "\n" + ("=" * (longest_line + padding)) - for line in msg.splitlines(): - print (" " * (padding / 2)) + line - print ("=" * (longest_line + padding)) + "\n" - - -# This function makes sure the user wants to process with his choice -def user_make_sure(question): - ans_from_user = raw_input(question) - if ans_from_user !="y": - print "\nExiting...\n" - exit() - - -# function to get y or n from the user and return True/ False accordingly -def get_yes_no_from_user(question): - ans_from_user = "" - while ans_from_user != "y" and ans_from_user != "n": - ans_from_user = raw_input(question) - if ans_from_user != "y" and ans_from_user != "n": - print "please answer 'y' or 'n' only!" - if ans_from_user == "y": - return True - else: - return False - - -# function to split parsed option to list by comma -def parser_split(option, opt, value, parser): - setattr(parser.values, option.dest, value.split(',')) - - -# function to validate pip packages -def validate_pip_packages(): - pip_version = getstatusoutput('pip -V') - if pip_version[0] != 0: - errorAndExit("python-pip is not installed, please install it before using this tool!\n" - "On MacOS, you can install it using one of the following:\n" - "'brew install python' or 'sudo easy_install pip") - - if 'python 2.7' not in pip_version[1]: - errorAndExit("This script currently supports python 2.7 only!") - - installed_packages = [pkg.split('==')[0] for pkg in getoutput('pip freeze').split('\n')] - required_packages = ['prettytable', 'requests'] - try: - for pkg in required_packages: - if pkg not in installed_packages: - raise DistributionNotFound - except DistributionNotFound: - errorAndExit("One of the required pip packages is not installed!\n" - "Please install all packages using the following command in the project's root folder:\n" - "pip install -r requirements.txt --user --upgrade") - - -# function validate suspension input -def validate_suspension(option, opt, value, parser): - if value not in valid_suspension_types: - errorAndExit("invalid suspension value: '{value}'!\nValid values are: {list}\n".format(value=value, list=", ".join(valid_suspension_types))) - # if value is set to ALL pass all process and remove ALL - if value == 'ALL': - processes_list = list(valid_suspension_types) - processes_list.remove("ALL") - setattr(parser.values, option.dest, processes_list) - else: - setattr(parser.values, option.dest, [value]) - - -##### MAIN #### -validate_pip_packages() -from prettytable import PrettyTable -from requests import put, get, post, delete - -# global variables -base_api_path = "https://api.spotinst.io/aws/ec2/group" -http_method = 'GET' # set as the default method, and overwrite when necessary -thread_list = [] -requests_queue = [] -group_placeholder = "__group_id__" -valid_suspension_types = ["AUTO_SCALE", "AUTO_HEALING", "OUT_OF_STRATEGY","PREVENTIVE_REPLACEMENT","REVERT_PREFERRED", "ALL"] -payload = None -user_validation = False - -# define help and options -usage = "Usage: %prog [options]" -parser = OptionParser(usage) -parser.add_option("-a", "--account", action="store", dest="account", - help="spotinst account id environment variable label - define an environment variable like spotinst_account_prod=\"act-asdfasdf\" and call with \"-a prod\"; SpotInst account ids can be found in the web interface under Settings > Account") -parser.add_option("-g", "--grep", type="string", action="callback", dest="grep", callback=parser_split, default="", help="text to filter groups by") -parser.add_option("-u", "--ungrep", type="string", action="callback", default="", callback=parser_split, dest="ungrep", help="text to exclude groups") -parser.add_option("-l", "--list", action="store_true", dest="list", help="show group list and exit") -parser.add_option("-j", "--json", action="store_true", dest="json", help="output pure json -- useful for piping into json parsers like jq") -parser.add_option("-d", "--get-data", action="store_true", dest="data", help="get groups config data") -parser.add_option("-s", "--get-status", action="store_true", dest="status", help="get groups current status") -parser.add_option("--get-health", action="store_true", dest="health_status", help="get groups health per instance") -parser.add_option("--min", action="store", dest="min", type=int, help="update group minimum capacity, must suuply with max and target") -parser.add_option("--target", action="store", dest="target", type=int, help="update group target capacity, must supply with min and max") -parser.add_option("--max", action="store", dest="max", type=int, help="update group maximum capacity, must supply with min and target") -parser.add_option("--scale-up", action="store", dest="scale_up", type=int, help="scale up group by X number of instances") -parser.add_option("--scale-down", action="store", dest="scale_down", type=int, help="scale down group by X number of instances") -parser.add_option("--suspend", type="string", default=None, action="callback", callback=validate_suspension, dest="suspend", help="suspend scaling or healing for a group") -parser.add_option("--unsuspend", type="string", default=None, action="callback", callback=validate_suspension, dest="unsuspend", help="unsuspend scaling or healing for a group") -parser.add_option("--suspension-status", action="store_true", dest="suspension", default="", help="get groups suspension status") -parser.add_option("--roll", action="store_true", dest="roll", help="roll a group, must supply batch-size, and grace-period") -parser.add_option("--batch-size", action="store", dest="batch", type=int, help="roll batch size - must supply with the roll flag") -parser.add_option("--grace-period", action="store", dest="grace", type=int, help="roll grace period - must supply with the roll flag") -parser.add_option("--replace-ami", action="store", dest="ami", help="replace AMI for group") -parser.add_option("--replace-health", action="store", dest="health", choices=['EC2', 'ELB', 'HCS'], help="replace health check type - valid options: EC2, ELB, HCS") -parser.add_option("--set-user-data", action="store", dest="set_user_data", help="updated user data - supply a file path which contains the user data script (cloud init)") -parser.add_option("--get-user-data", action="store_true", dest="get_user_data", help="fetch the user data script (cloud init)") -parser.add_option("--roll-status", action="store_true", dest="roll_status", help="check the status of deployments") -parser.add_option("-y", "--skip-validation", action="store_true", dest="skip_validation", help="skip prompt validation for non-interactive mode") -(options, args) = parser.parse_args() - -# show help as default and exit -if len(argv) == 1: - system("python " + argv[0] + " -h") - exit() - -# validate input -if any([options.min, options.max, options.target]) and not all([options.min, options.max, options.target]): - errorAndExit("you must supply max, min and target together!") -if options.roll and not all([options.batch, options.grace]): - errorAndExit("you must supply batch size and grace period with the roll flag!") -if options.json: - if options.get_user_data: - errorAndExit("user data cannot be printed as a json output!") - -# check token -token = environ.get('spotinst_token') -if not token: - errorAndExit("you must define an environment variable called 'spotinst_token' with a valid token (use export or .bashrc file)!") - -# check for Spotinst account -if options.account: - environment_var = "spotinst_account_{options.account}".format(**locals()) - account = environ.get(environment_var) - if account is None: - errorAndExit("could not find the environment variable: {environment_var}".format(**locals())) -else: - account = None - -# get all groups that matches the search -groups_to_update = get_groups(base_api_path, token, account, options.grep, options.ungrep) -if len(groups_to_update) == 0: - errorAndExit("could not find any groups for the search term: {}".format(options.grep)) -print_all_groups(groups_to_update) - -# if the list flag was triggered, need to exit -if options.list: - exit() - -# get data request -if options.data: - req_uri = "{}/{}".format(base_api_path, group_placeholder) - -# get status request -elif options.status: - req_uri = "{}/{}/status".format(base_api_path, group_placeholder) - -elif options.health_status: - req_uri = "{}/{}/instanceHealthiness".format(base_api_path, group_placeholder) - -# get user data script -elif options.get_user_data: - req_uri = "{}/{}".format(base_api_path, group_placeholder) - -# get roll status -elif options.roll_status: - req_uri = "{}/{}/roll".format(base_api_path, group_placeholder) - -# get suspension status -elif options.suspension: - req_uri = "{}/{}/suspension".format(base_api_path, group_placeholder) - -# suspend / un-suspend equest -elif any([options.suspend, options.unsuspend]): - req_uri = "{}/{}/suspension".format(base_api_path, group_placeholder) - if options.suspend: - http_method = 'POST' - payload = json_dumps({"processes": options.suspend}) - elif options.unsuspend: - http_method = 'DELETE' - payload = json_dumps({"processes": options.unsuspend}) - -# scale up / down -elif any([options.scale_down, options.scale_up]): - http_method = 'PUT' - if options.scale_up: - command = "up" - adjustment = options.scale_up - else: - command = "down" - adjustment = options.scale_down - req_uri = "{}/{}/scale/{}\?adjustment={}".format(base_api_path, group_placeholder, command, adjustment) - -# set target, max and min -elif all([options.min, options.target, options.max]): - http_method = 'PUT' - payload = json_dumps({ "group": { "capacity": { "target": options.target, "minimum": options.min , "maximum": options.max }}}) - req_uri = "{}/{}".format(base_api_path, group_placeholder) - -# roll request -elif all([options.roll, options.batch, options.grace]): - http_method = 'PUT' - req_uri = "{}/{}/roll".format(base_api_path, group_placeholder) - payload = json_dumps({ "batchSizePercentage" : options.batch, "gracePeriod" : options.grace }) - -# replace ami and health type -elif any([options.ami, options.health]): - http_method = 'PUT' - req_uri = "{}/{}".format(base_api_path, group_placeholder) - if options.ami: - payload = json_dumps({ "group": { "compute": { "launchSpecification": { "imageId": options.ami }}}}) - else: - payload = json_dumps({"group": {"compute": {"launchSpecification": {"healthCheckType": options.health}}}}) - -# replace user data script -elif options.set_user_data: - if not path.isfile(options.set_user_data): - errorAndExit("{} is not a valid file name!".format(options.set_user_data)) - with open(options.set_user_data, "r") as f: - user_data_script = f.read() - user_data_script = b64encode(user_data_script) - req_uri = "{}/{}".format(base_api_path, group_placeholder) - http_method = 'PUT' - payload = json_dumps({ "group": { "compute": { "launchSpecification": { "userData": user_data_script }}}}) - -else: - errorAndExit("Wrong options set - existing") - - -# make sure the user wants to continue with the current filter (unless the req type is GET) -if not user_validation and http_method != 'GET': - if options.json and not options.skip_validation: - errorAndExit("you must supply the -y option for json format") - if not options.skip_validation: - user_make_sure("\nAre you sure you want to updated these groups [y/n]? ") - else: - if not options.json: - print "Found skip validation option, skipping user prompt...\n" - user_validation = True - if not options.json: - print_message("Updating groups - hold on...") - - -# loop through each group and update -for group, group_values in groups_to_update.iteritems(): - group_id = group_values[0] - group_req_uri = req_uri.replace(group_placeholder, group_id) - try: - open_thread(query_thread, (requests_queue, group, group_req_uri, token, http_method, payload, account)) - except Exception as e: - errorAndExit(e) - -# wait for all threads to finish -for thread in thread_list: - thread.join() - -# print the results -for result in requests_queue: - http_method = result[0] - request_result = result[1] - request_group = result[2] - group_result = {"group": request_group, "content": json_loads(request_result.content), "request_status_code": request_result.status_code} - if options.json: - print json_dumps(group_result, indent=2, sort_keys=True) - else: - print_header(request_group) - if request_result.status_code == 200: - if http_method == 'GET': - if not options.get_user_data: - print request_result.content - else: - print b64decode(json_loads(request_result.content)['response']['items'][0]['compute']['launchSpecification']['userData']) - else: - print_in_color('green', "Update was successful!") - else: - try: - data = json_loads(request_result.content) - except: - data = None - print_in_color('red', "Error while updating group!\nstatus code: {}".format(request_result.status_code)) - if data is not None: - print "request content: {}".format(data['response']['errors'][0]['message']) - -if not options.json: - print_message("Done executing on all groups!") diff --git a/spotinstcli/__init__.py b/spotinstcli/__init__.py new file mode 100755 index 0000000..c3d9567 --- /dev/null +++ b/spotinstcli/__init__.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python + +from optparse import OptionParser + +valid_suspension_types = ["AUTO_SCALE", "AUTO_HEALING", "OUT_OF_STRATEGY","PREVENTIVE_REPLACEMENT","REVERT_PREFERRED", "ALL"] + +# function to split parsed option to list by comma +def parser_split(option, opt, value, parser): + setattr(parser.values, option.dest, value.split(',')) + +# function validate suspension input +def validate_suspension(option, opt, value, parser): + if value not in valid_suspension_types: + print "invalid suspension value: '{value}'!\nValid values are: {list}\n".format(value=value, list=", ".join(valid_suspension_types)) + exit() + # if value is set to ALL pass all process and remove ALL + if value == 'ALL': + processes_list = list(valid_suspension_types) + processes_list.remove("ALL") + setattr(parser.values, option.dest, processes_list) + else: + setattr(parser.values, option.dest, [value]) + + +def main(): + # define help and options + usage = "Usage: %prog [options]" + parser = OptionParser(usage) + parser.add_option("-a", "--account", action="store", dest="account", + help="spotinst account id environment variable label - define an environment variable like " + "spotinst_account_prod=\"act-asdfasdf\" and call with \"-a prod\"; " + "SpotInst account ids can be found in the web interface under Settings > Account") + + parser.add_option("-g", "--grep", type="string", action="callback", dest="grep", callback=parser_split, default="", + help="text to filter groups by") + + parser.add_option("-u", "--ungrep", type="string", action="callback", default="", callback=parser_split, + dest="ungrep", help="text to exclude groups") + + parser.add_option("-l", "--list", action="store_true", dest="list", help="show group list and exit") + + parser.add_option("-j", "--json", action="store_true", dest="json", + help="output pure json -- useful for piping into json parsers like jq") + + parser.add_option("-d", "--get-data", action="store_true", dest="data", help="get groups config data") + + parser.add_option("-s", "--get-status", action="store_true", dest="status", help="get groups current status") + + parser.add_option("--get-health", action="store_true", dest="health_status", help="get groups health per instance") + + parser.add_option("--min", action="store", dest="min", type=int, + help="update group minimum capacity, must suuply with max and target") + + parser.add_option("--target", action="store", dest="target", type=int, + help="update group target capacity, must supply with min and max") + + parser.add_option("--max", action="store", dest="max", type=int, + help="update group maximum capacity, must supply with min and target") + + parser.add_option("--scale-up", action="store", dest="scale_up", type=int, + help="scale up group by X number of instances") + + parser.add_option("--scale-down", action="store", dest="scale_down", type=int, + help="scale down group by X number of instances") + + parser.add_option("--suspend", type="string", default=None, action="callback", callback=validate_suspension, + dest="suspend", help="suspend scaling or healing for a group") + + parser.add_option("--unsuspend", type="string", default=None, action="callback", callback=validate_suspension, + dest="unsuspend", help="unsuspend scaling or healing for a group") + + parser.add_option("--suspension-status", action="store_true", dest="suspension", default="", + help="get groups suspension status") + + parser.add_option("--roll", action="store_true", dest="roll", + help="roll a group, must supply batch-size, and grace-period") + + parser.add_option("--batch-size", action="store", dest="batch", type=int, + help="roll batch size - must supply with the roll flag") + + parser.add_option("--grace-period", action="store", dest="grace", type=int, + help="roll grace period - must supply with the roll flag") + + parser.add_option("--replace-ami", action="store", dest="ami", help="replace AMI for group") + + parser.add_option("--replace-health", action="store", dest="health", choices=['EC2', 'ELB', 'HCS'], + help="replace health check type - valid options: EC2, ELB, HCS") + + parser.add_option("--set-user-data", action="store", dest="set_user_data", + help="updated user data - supply a file path which contains the user data script (cloud init)") + + parser.add_option("--get-user-data", action="store_true", dest="get_user_data", + help="fetch the user data script (cloud init)") + + parser.add_option("--roll-status", action="store_true", dest="roll_status", help="check the status of deployments") + + parser.add_option("-y", "--skip-validation", action="store_true", dest="skip_validation", + help="skip prompt validation for non-interactive mode") + + (options, _) = parser.parse_args() + + import spotinstcli + spotinstcli.main(options) + +if __name__ == "__main__": + main() + + diff --git a/spotinstcli/shim_script.py b/spotinstcli/shim_script.py new file mode 100644 index 0000000..3a49c94 --- /dev/null +++ b/spotinstcli/shim_script.py @@ -0,0 +1,12 @@ +#!/usr/local/opt/python@2/bin/python2.7 +# EASY-INSTALL-ENTRY-SCRIPT: 'spotinstcli==0.1.2','console_scripts','spotinst-cli' +__requires__ = 'spotinstcli==0.1.3' +import re +import sys +from pkg_resources import load_entry_point + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) + sys.exit( + load_entry_point('spotinstcli==0.1.3', 'console_scripts', 'spotinst-cli')() + ) \ No newline at end of file diff --git a/spotinstcli/spotfunctions.py b/spotinstcli/spotfunctions.py new file mode 100755 index 0000000..fef36c3 --- /dev/null +++ b/spotinstcli/spotfunctions.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python + +try: + # native + from json import loads as json_loads, dumps as json_dumps + from commands import getoutput, getstatusoutput + from threading import Thread + from pkg_resources import DistributionNotFound + from collections import OrderedDict + from sys import stdout, exit as sys_exit + # 3rd party + from prettytable import PrettyTable + from requests import put, get, post, delete + from PyInquirer import prompt + from clint.textui import colored, puts +except ImportError: + print "Error while importing packages - please install all of the required packages in requirements.txt" + sys_exit(1) + + +# This function opens a thread with args +def open_thread(thread_list, thread_target, thread_args): + t = Thread(target=thread_target, args = (thread_args)) + t.daemon = True + t.start() + thread_list.append(t) + + +# This function prints error and exits +def error_and_exit(exception_message): + print "\nERROR - %s\n" %(exception_message) + exit() + + +# This function prints a text in color and returns to normal +def print_in_color(color, msg): + if color == 'red': + puts(colored.red(msg)) + elif color == 'green': + puts(colored.green(msg)) + + +# This function prints a group name +def print_header(text): + text_len = len(text) + print "\n" + ("-" * text_len) + print text + print ("-" * text_len) + + +# This function queries spotinst API endpoint and returns the result to a queue +def query_thread(queue, group, api_url, spotinst_token, http_method='GET', req_payload=None, account=None): + try: + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % spotinst_token, + } + if account is not None: + api_url = "{api_url}?accountId={account}".format(**locals()) + + result = "" + if http_method == 'GET': + result = get(api_url, headers=headers) + elif http_method == 'PUT': + result = put(api_url, headers=headers, data=req_payload) + elif http_method == 'POST': + result = post(api_url, headers=headers, data=req_payload) + elif http_method == 'DELETE': + result = delete(api_url, headers=headers, data=req_payload) + else: + error_and_exit("wrong http method: {http_method}".format(**locals())) + queue.append([http_method, result, group]) + except Exception as e: + error_and_exit(e.message) + + +# This function queries spotinst API endpoint +def query_api(api_url, spotinst_token, http_method='GET', req_payload=None, account=None): + try: + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % spotinst_token, + } + if account is not None: + api_url = "{api_url}?accountId={account}".format(**locals()) + + if http_method == 'GET': + return get(api_url, headers=headers) + elif http_method == 'PUT': + return put(api_url, headers=headers, data=req_payload) + elif http_method == 'POST': + return post(api_url, headers=headers, data=req_payload) + elif http_method == 'DELETE': + return delete(api_url, headers=headers, data=req_payload) + else: + error_and_exit("wrong http method: {http_method}".format(**locals())) + except Exception as e: + error_and_exit(e.message) + + +# This function returns a groups dict, filtered by name (contains) or all if parameter is empty +def get_groups(base_api_path, token, account, grep_list, ungrep_list, json_format): + if not json_format: + print_message("Getting groups from Spotisnt, please wait...") + result = query_api(base_api_path, token, account=account) + if result.status_code > 299: + error_and_exit("bad response from Spotinst!\n{}".format(result.content)) + try: + data = json_loads(result.content) + except Exception as e: + error_and_exit("while loading json response: {e.message}".format(**locals())) + # apply grep and ungrep filters + groups = {} + all_groups = data['response']['items'] + if len(grep_list) == 0: + for group in all_groups: + groups[group['name']] = [group['id'], group['capacity']['minimum'], group['capacity']['target'], group['capacity']['maximum']] + else: + for group in all_groups: + group_name = group['name'].lower() + if [grep_term for grep_term in grep_list if grep_term.lower() in group_name] and not [ungrep_term for ungrep_term in ungrep_list if ungrep_term.lower() in group_name]: + groups[group['name']] = [group['id'], group['capacity']['minimum'], group['capacity']['target'], group['capacity']['maximum']] + # return a sorted list of groups + return OrderedDict(sorted(groups.items())) + + +# This function prints groups as a table +def print_all_groups(groups_to_print, json_format, list_only): + if not json_format: + table = PrettyTable(['#' ,'Group name', 'ID', 'Min', 'Target', 'Max']) + table.align = "l" + table.border = True + counter = 1 + for key, value in groups_to_print.iteritems(): + table.add_row([counter, key, value[0], value[1], value[2], value[3]]) + counter += 1 + print_message("Found the following groups: ") + print "{table}\n".format(**locals()) + else: + if list_only: + groups_to_print = { "groups" : groups_to_print.keys()} + print json_dumps(groups_to_print, indent=2) + + +# This function prints a bold message +def print_message(msg): + all_lines = msg.splitlines() + longest_line = 0 + for line in all_lines: + if len(line.strip()) > longest_line: + longest_line = len(line.strip()) + padding = 8 + print "\n" + ("=" * (longest_line + padding)) + for line in msg.splitlines(): + print (" " * (padding / 2)) + line + print ("=" * (longest_line + padding)) + "\n" + + +# This function makes sure the user wants to process with his choice +def prompt_for_conformation(): + question = [ + { + 'type': 'confirm', + 'message': 'Are you sure you want to updated these groups?', + 'name': 'confirm', + 'default': False, + } + ] + try: + return prompt(question)['confirm'] + except KeyError: + return False + + + diff --git a/spotinstcli/spotinstcli.py b/spotinstcli/spotinstcli.py new file mode 100755 index 0000000..53a2d4f --- /dev/null +++ b/spotinstcli/spotinstcli.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python + +# This tool allows the user to interact with spotinst API +# run the script with the -h flag for help + +import spotfunctions +from json import loads as json_loads, dumps as json_dumps +from os import environ +from sys import argv, exit as sys_exit +from os import path +from base64 import b64encode, b64decode + + +def main(args): + # main variables + base_api_path = "https://api.spotinst.io/aws/ec2/group" + http_method = 'GET' # set as the default method, and overwrite when necessary + thread_list = [] + requests_queue = [] + group_placeholder = "__group_id__" + payload = None + user_validation = False + + # validate input + if any([args.min, args.max, args.target]) and not all([args.min, args.max, args.target]): + spotfunctions.error_and_exit("you must supply max, min and target together!") + if args.roll and not all([args.batch, args.grace]): + spotfunctions.error_and_exit("you must supply batch size and grace period with the roll flag!") + if args.json: + if args.get_user_data: + spotfunctions.error_and_exit("user data cannot be printed as a json output!") + + # check token + token = environ.get('spotinst_token') + if not token: + spotfunctions.error_and_exit("you must define an environment variable called 'spotinst_token' with a valid token (use export or .bashrc file)!") + + # check for Spotinst account + if args.account: + environment_var = "spotinst_account_{args.account}".format(**locals()) + account = environ.get(environment_var) + if account is None: + spotfunctions.error_and_exit("could not find the environment variable: {environment_var}".format(**locals())) + else: + account = None + + # get all groups that matches the search + groups_to_update = spotfunctions.get_groups(base_api_path, token, account, args.grep, args.ungrep, args.json) + if len(groups_to_update) == 0: + spotfunctions.error_and_exit("could not find any groups for the search term: {}".format(args.grep)) + spotfunctions.print_all_groups(groups_to_update, args.json, args.list) + + # if the list flag was triggered, need to exit + if args.list: + sys_exit(0) + + # get data request + if args.data: + req_uri = "{}/{}".format(base_api_path, group_placeholder) + + # get status request + elif args.status: + req_uri = "{}/{}/status".format(base_api_path, group_placeholder) + + elif args.health_status: + req_uri = "{}/{}/instanceHealthiness".format(base_api_path, group_placeholder) + + # get user data script + elif args.get_user_data: + req_uri = "{}/{}".format(base_api_path, group_placeholder) + + # get roll status + elif args.roll_status: + req_uri = "{}/{}/roll".format(base_api_path, group_placeholder) + + # get suspension status + elif args.suspension: + req_uri = "{}/{}/suspension".format(base_api_path, group_placeholder) + + # suspend / un-suspend equest + elif any([args.suspend, args.unsuspend]): + req_uri = "{}/{}/suspension".format(base_api_path, group_placeholder) + if args.suspend: + http_method = 'POST' + payload = json_dumps({"processes": args.suspend}) + elif args.unsuspend: + http_method = 'DELETE' + payload = json_dumps({"processes": args.unsuspend}) + + # scale up / down + elif any([args.scale_down, args.scale_up]): + http_method = 'PUT' + if args.scale_up: + command = "up" + adjustment = args.scale_up + else: + command = "down" + adjustment = args.scale_down + req_uri = "{}/{}/scale/{}\?adjustment={}".format(base_api_path, group_placeholder, command, adjustment) + + # set target, max and min + elif all([args.min, args.target, args.max]): + http_method = 'PUT' + payload = json_dumps({ "group": { "capacity": { "target": args.target, "minimum": args.min , "maximum": args.max }}}) + req_uri = "{}/{}".format(base_api_path, group_placeholder) + + # roll request + elif all([args.roll, args.batch, args.grace]): + http_method = 'PUT' + req_uri = "{}/{}/roll".format(base_api_path, group_placeholder) + payload = json_dumps({ "batchSizePercentage" : args.batch, "gracePeriod" : args.grace }) + + # replace ami and health type + elif any([args.ami, args.health]): + http_method = 'PUT' + req_uri = "{}/{}".format(base_api_path, group_placeholder) + if args.ami: + payload = json_dumps({ "group": { "compute": { "launchSpecification": { "imageId": args.ami }}}}) + else: + payload = json_dumps({"group": {"compute": {"launchSpecification": {"healthCheckType": args.health}}}}) + + # replace user data script + elif args.set_user_data: + if not path.isfile(args.set_user_data): + spotfunctions.error_and_exit("{} is not a valid file name!".format(args.set_user_data)) + with open(args.set_user_data, "r") as f: + user_data_script = f.read() + user_data_script = b64encode(user_data_script) + req_uri = "{}/{}".format(base_api_path, group_placeholder) + http_method = 'PUT' + payload = json_dumps({ "group": { "compute": { "launchSpecification": { "userData": user_data_script }}}}) + + else: + # user supplied no other choice other than grep / ungrep - exit + sys_exit(0) + + + # make sure the user wants to continue with the current filter (unless the req type is GET) + if not user_validation and http_method != 'GET': + if args.json and not args.skip_validation: + spotfunctions.error_and_exit("you must supply the -y option for json format") + if not args.skip_validation: + if not spotfunctions.prompt_for_conformation(): + print "Existing...\n" + sys_exit(0) + else: + if not args.json: + print "Found skip validation option, skipping user prompt...\n" + user_validation = True + if not args.json: + spotfunctions.print_message("Updating groups - hold on...") + + # loop through each group and update + for group, group_values in groups_to_update.iteritems(): + group_id = group_values[0] + group_req_uri = req_uri.replace(group_placeholder, group_id) + try: + spotfunctions.open_thread(thread_list, spotfunctions.query_thread, (requests_queue, group, group_req_uri, token, http_method, payload, account)) + except Exception as e: + spotfunctions.error_and_exit(e) + + # wait for all threads to finish + for thread in thread_list: + thread.join() + + # print the results + for result in requests_queue: + http_method = result[0] + request_result = result[1] + request_group = result[2] + group_result = {"group": request_group, "content": json_loads(request_result.content), "request_status_code": request_result.status_code} + if args.json: + print json_dumps(group_result, indent=2, sort_keys=True) + else: + spotfunctions.print_header(request_group) + if request_result.status_code == 200: + if http_method == 'GET': + if not args.get_user_data: + print request_result.content + else: + print b64decode(json_loads(request_result.content)['response']['items'][0]['compute']['launchSpecification']['userData']) + else: + spotfunctions.print_in_color('green', "Update was successful!") + else: + try: + data = json_loads(request_result.content) + except: + data = None + spotfunctions.print_in_color('red', "Error while updating group!\nstatus code: {}".format(request_result.status_code)) + if data is not None: + print "request content: {}".format(data['response']['errors'][0]['message']) + + if not args.json: + spotfunctions.print_message("Done executing on all groups! ") + + +if __name__ == "__main__": + args = argv[1] + main(args) \ No newline at end of file