├── assh ├── __init__.py ├── __main__.py ├── bin │ └── assh └── assh.py ├── .gitignore ├── setup.py ├── LICENSE └── README.md /assh/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assh/__main__.py: -------------------------------------------------------------------------------- 1 | import assh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | history.txt 2 | .project/ 3 | .pydevproject/ 4 | *.pyc 5 | *.so 6 | *.log 7 | *.swp 8 | build/ 9 | dist/ 10 | MANIFEST 11 | env/ 12 | log/ 13 | *.egg-info/ 14 | .idea/ 15 | .settings/ 16 | .pydevproject 17 | dump.rdb 18 | *~ 19 | celerybeat-schedule.db 20 | .project 21 | .settings 22 | *.py[cod] 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Packages 28 | *.egg 29 | *.egg-info 30 | dist 31 | build 32 | eggs 33 | parts 34 | #bin 35 | var 36 | sdist 37 | develop-eggs 38 | .installed.cfg 39 | lib 40 | lib64 41 | __pycache__ 42 | 43 | # Installer logs 44 | pip-log.txt 45 | 46 | # Unit test / coverage reports 47 | .coverage 48 | .tox 49 | nosetests.xml 50 | 51 | 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | VERSION = '0.1.11' 6 | DESCRIPTION = 'aws server list in ncurses - select your server/s and ssh to them' 7 | 8 | setup( 9 | name='assh', 10 | version=VERSION, 11 | description=DESCRIPTION, 12 | author='ybrs', 13 | license='MIT', 14 | url="http://github.com/ybrs/assh", 15 | author_email='aybars.badur@gmail.com', 16 | packages=['assh'], 17 | install_requires=['hst', 'boto', 'plotly==1.9.6'], 18 | scripts=['./assh/bin/assh'], 19 | classifiers = [ 20 | 'Development Status :: 4 - Beta', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Operating System :: OS Independent', 24 | 'Programming Language :: Python', 25 | 'Topic :: Database', 26 | 'Topic :: Software Development :: Libraries :: Python Modules', 27 | ] 28 | ) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 aybars badur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assh/bin/assh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | pipe=0 3 | 4 | for i in "$@" 5 | do 6 | case $i in 7 | -p|--pipe-output) 8 | pipe=1 9 | ;; 10 | esac 11 | done 12 | 13 | 14 | f="/tmp/$$-command.hst" 15 | # we install via pip so the python we use is, resolve $SOURCE until the file is no longer a symlink 16 | # so one can symlink to /usr/local/bin etc... 17 | # stolen from https://github.com/facebook/PathPicker/blob/master/fpp 18 | SOURCE=$0 19 | # resolve $SOURCE until the file is no longer a symlink 20 | while [ -h "$SOURCE" ]; do 21 | BASEDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 22 | SOURCE="$(readlink "$SOURCE")" 23 | # if $SOURCE was a relative symlink, we need to resolve it relative to 24 | # the path where the symlink file was located 25 | [[ $SOURCE != /* ]] && SOURCE="$BASEDIR/$SOURCE" 26 | done 27 | BASEDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 28 | PYTHON=`which python` 29 | # asshpy="$($PYTHON -c 'from assh import assh; print assh.__file__.replace(".pyc", ".py")' 2>/dev/null)" 30 | # if [ $? -ne 0 ]; then 31 | # # if we are in a virtualenv 32 | # PYTHON="$BASEDIR/python" 33 | # asshpy="$($PYTHON -c 'from assh import assh; print assh.__file__.replace(".pyc", ".py")' 2>/dev/null)" 34 | # if [ $? -ne 0 ]; then 35 | # PYTHON="/usr/bin/python" 36 | # asshpy="$($PYTHON -c 'from assh import assh; print assh.__file__.replace(".pyc", ".py")' 2>/dev/null)" 37 | # CMD = "$PYTHON -m assh.assh" 38 | # fi 39 | # fi 40 | $PYTHON -m assh.assh --out=$f "$@" 3>&1 1>&2 41 | 42 | if [[ $? != 0 ]]; then exit $?; fi 43 | if [ -f "$f" ] 44 | then 45 | if [[ $pipe == "1" ]]; then 46 | cat $f 47 | else 48 | $SHELL -i $f < /dev/tty 49 | fi 50 | fi -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | assh - select your servers from aws with ncurses and then ssh easily - or do something else with them. 2 | 3 | [![asciicast](https://asciinema.org/a/2ga28o9gnondowm60ol7iad69.png)](https://asciinema.org/a/2ga28o9gnondowm60ol7iad69) 4 | 5 | How 6 | ========================== 7 | assh brings a list of servers from your AWS account. Search, move, Hit enter to select one, 8 | then ssh (or whatever you want) to them. 9 | 10 | Its extendible, so you can add some other commands - use it with fabric, ansible, ssh etc. 11 | 12 | 13 | Why 14 | ========================== 15 | Because servers come and go, and i started hating the questions "Do we have 2 appservers in X project or 3 ? ", "was it app4.x.project.com or app5.x.project.com". 16 | 17 | Installation 18 | ========================= 19 | use pip to install 20 | 21 | pip install assh 22 | 23 | then create a python file in your ~/.assh directory with somename 24 | 25 | mkdir ~/.assh 26 | vim ~/.assh/project.py 27 | 28 | add your AWS account info 29 | 30 | AWS_ACCESS_KEY_ID = 'XXXXX' 31 | AWS_SECRET_ACCESS_KEY = 'YYYY' 32 | AWS_REGION = 'us-east-1' 33 | 34 | a region also can be a list eg 35 | AWS_REGION = ['us-east-1', 'us-east-2'] 36 | 37 | and then you can 38 | 39 | assh project ssh 40 | 41 | select your fav. server and hit enter. 42 | 43 | you can also extend and override commands in project.py file 44 | 45 | def cmd_SSH(self, line): 46 | return 'ssh -i ~/.ssh/project.pem ubuntu@%s' % line 47 | 48 | for more examples about adding your own commands, please see http://github.com/ybrs/assh/examples/project.py 49 | 50 | Usage 51 | =========================== 52 | 53 | using fabric 54 | 55 | assh project fab -P -- 'uptime && df -h' 56 | 57 | assh project fab -P uptime 58 | 59 | assh project ssh 60 | 61 | assh project graph_cpu 62 | 63 | you can also filter by tags 64 | 65 | # show only worker instances - ones tagged with Role as worker 66 | assh --filter-tag=Role:worker project ssh 67 | 68 | # if we can only show one instance we don't show curses ui and jump directly to command 69 | assh --filter-tag=Name:app1 project ssh 70 | 71 | # because filtering with name is so common there is a shortcut 72 | assh -N app1 project ssh 73 | 74 | # we can pipe the servers name to another application with noop command 75 | assh -N app1 project noop | xargs ssh -i ~/.ssh/foo.pem 76 | 77 | -------------------------------------------------------------------------------- /assh/assh.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | import boto.ec2 4 | 5 | import imp 6 | import subprocess 7 | 8 | from hst.hst import main 9 | import os 10 | import sys 11 | if os.name != 'posix': 12 | sys.exit('platform not supported') 13 | import logging 14 | import os 15 | import argparse 16 | from hst.hst import Picker, QuitException 17 | from functools import partial 18 | 19 | import locale 20 | locale.setlocale(locale.LC_ALL,"") 21 | 22 | logger = logging.getLogger(__name__) 23 | import curses 24 | 25 | 26 | class DummyRegion(object): 27 | @property 28 | def name(self): 29 | return "region" 30 | 31 | class DummyInstance(object): 32 | def __init__(self, ip): 33 | self.ip = ip 34 | 35 | @property 36 | def id(self): 37 | return "x" 38 | 39 | @property 40 | def region(self): 41 | return DummyRegion() 42 | 43 | @property 44 | def name(self): 45 | return "name" 46 | 47 | @property 48 | def public_dns_name(self): 49 | return self.ip 50 | 51 | @property 52 | def tags(self): 53 | return {} 54 | 55 | def get_instances(region, aws_key, aws_secret, tags): 56 | """ 57 | 58 | :param region: 59 | :param aws_key: 60 | :param aws_secret: 61 | :param tags: is a dictionary, eg: {'Name': 'App1'} 62 | :return: 63 | """ 64 | # return [DummyInstance("hello - %s" % i) for i in range(1, 100)] 65 | conn = boto.ec2.connect_to_region(region, # n virginia 66 | aws_access_key_id=aws_key, 67 | aws_secret_access_key=aws_secret) 68 | 69 | filters = {} 70 | if tags: 71 | filter_tags = {} 72 | for tn, tv in tags.iteritems(): 73 | filter_tags['tag:%s' % tn] = tv 74 | filters.update(filter_tags) 75 | filters.update({'instance-state-name':'running'}) 76 | 77 | reservations = conn.get_all_instances(filters=filters) 78 | instances = [] 79 | for r in reservations: 80 | for i in r.instances: 81 | instances.append(i) 82 | return instances 83 | 84 | class SimpleLineLoader(object): 85 | def __init__(self, aws_region, aws_key, aws_secret, tags=None): 86 | self.aws_key = aws_key 87 | self.aws_secret = aws_secret 88 | if isinstance(aws_region, str): 89 | aws_region = [aws_region] 90 | self.aws_regions = aws_region 91 | self.tags = tags 92 | 93 | def load(self): 94 | self.instances = [] 95 | for region in self.aws_regions: 96 | instances = get_instances(region, self.aws_key, self.aws_secret, tags=self.tags) 97 | self.instances += instances 98 | lines = [] 99 | for i in self.instances: 100 | line = [] 101 | line.append(i.public_dns_name.ljust(50)) 102 | line.append(' | ') 103 | line.append(i.id.ljust(10)) 104 | line.append(' | %s' % i.region.name) 105 | line.append(' | ') 106 | 107 | for k, v in i.tags.items(): 108 | line.append('%s = %s' % (k, v)) 109 | lines.append(' '.join(line)) 110 | return lines 111 | 112 | class AsshPicker(Picker): 113 | 114 | output_only = False 115 | 116 | def get_hostname_from_line(self, line): 117 | return line.split('|')[0].strip() 118 | 119 | def get_instance_id_from_line(self, line): 120 | return line.split('|')[1].strip() 121 | 122 | def get_cmd_fn_from_modules(self, *modules): 123 | for module in modules: 124 | fn = getattr(module, 'cmd_%s' % self.args.command.upper(), None) 125 | if fn: 126 | return fn 127 | 128 | def get_cmd_fn(self, cmd_name): 129 | from . import commands 130 | fn = self.get_cmd_fn_from_modules(self.settings, commands) 131 | if fn: 132 | return partial(fn, self) 133 | 134 | # look for builtins 135 | return getattr(self, 'cmd_%s' % self.args.command.upper()) 136 | 137 | def write_output(self, line): 138 | with open(self.args.out, 'w') as f: 139 | if self.output_only: 140 | out = '''cat <<'HereDocFromASSH' \n %s \nHereDocFromASSH\n\n''' % line 141 | f.write(out.encode('utf8')) 142 | else: 143 | f.write(line.encode('utf8')) 144 | 145 | def show_output(self, t): 146 | self.output_only = t 147 | 148 | def create_menu(self): 149 | self.win.addstr(3, 10, "xxxxxxxxxxx", curses.color_pair(1)) 150 | for i in range(0, 10): 151 | self.win.addstr(4 + i, 10, "x %s x" % i, curses.color_pair(1)) 152 | self.win.addstr(4 + 10, 10, "xxxxxxxxxxx", curses.color_pair(1)) 153 | 154 | def refresh_window(self, pressed_key=None): 155 | self.lineno = 0 156 | if pressed_key: 157 | self.search_txt = self.append_after_cursor(self.search_txt, pressed_key) 158 | 159 | # curses.endwin() 160 | self.win.erase() 161 | 162 | self.print_header(self.search_txt, cursor=True) 163 | 164 | logger.debug("======================== refresh window ======================") 165 | self.which_lines(self.search_txt) 166 | 167 | if not self.last_lines: 168 | self.print_line("Results [%s]" % self.index.size(), highlight=True) 169 | else: 170 | self.print_line("Results - [%s]" % len(self.last_lines), highlight=True) 171 | 172 | max_y, max_x = self.get_max_viewport() 173 | 174 | if self.selected_lineno > len(self.which_lines(self.search_txt)) - 1: 175 | self.selected_lineno = len(self.which_lines(self.search_txt)) - 1 176 | 177 | logger.debug("self.multiple selected %s", self.multiple_selected) 178 | 179 | for i, p in enumerate(self.last_lines[0:max_y]): 180 | selected = i == self.selected_lineno 181 | pending = (self.pick_line(i) in self.multiple_selected) 182 | logger.debug("is pending %s [%s____%s]", pending, self.pick_line(i), self.multiple_selected) 183 | try: 184 | if pending: 185 | line = u"[x] %s" % p[1] 186 | else: 187 | line = u"[ ] %s" % p[1] 188 | except: 189 | logger.exception("exception in adding line %s", p) 190 | else: 191 | try: 192 | self.print_line(line.strip(), highlight=selected, semi_highlight=pending) 193 | except curses.error: 194 | break 195 | 196 | # self.create_menu() 197 | 198 | try: 199 | s = 'type something to search | [F5] copy | [F6] multiple selection | [TAB] complete to current | [ENTER] run | [ESC] quit' 200 | self.print_footer("[%s] %s" % (self.mode, s)) 201 | except curses.error as e: 202 | pass 203 | self.win.refresh() 204 | 205 | def key_ENTER(self): 206 | # if not self.args.command: 207 | # self.create_menu() 208 | # self.refresh_window() 209 | # return 210 | line = self.pick_line() 211 | self.no_enter_yet = False 212 | logger.debug("selected_lineno: %s", line) 213 | 214 | if len(self.multiple_selected) == 0: 215 | self.multiple_selected = [line] 216 | 217 | line = self.args.separator.join([self.get_hostname_from_line(l) for l in self.multiple_selected]) 218 | 219 | logger.debug("selected line: %s", line) 220 | 221 | if self.args.eval: 222 | if self.args.replace: 223 | line = self.args.eval.replace(self.args.replace, line) 224 | else: 225 | line = "%s %s" % (self.args.eval, line) 226 | 227 | if self.args.command: 228 | fn = self.get_cmd_fn(self.args.command) 229 | line = fn(line) 230 | 231 | self.write_output(line) 232 | 233 | raise QuitException() 234 | 235 | def key_DOWN(self): 236 | max_y, max_x = self.get_max_viewport() 237 | 238 | if self.selected_lineno < max_y - 1: 239 | self.selected_lineno += 1 240 | 241 | self.refresh_window() 242 | 243 | def cmd_NOOP(self, line): 244 | """ 245 | a command that does nothing, just prints output 246 | """ 247 | return 'echo %s' % line 248 | 249 | def cmd_SSH(self, line): 250 | return 'ssh %s' % line 251 | 252 | def cmd_FAB(self, line): 253 | rest = subprocess.list2cmdline(self.args.rest) 254 | return 'fab -H %s %s' % (line, rest) 255 | 256 | def get_instance_by_public_ip(self, public_ip): 257 | for i in self.loader.instances: 258 | if i.public_dns_name == public_ip: 259 | return i 260 | 261 | def cmd_GRAPH_CPU(self, line): 262 | instance = self.get_instance_by_public_ip(line) 263 | import boto.ec2.cloudwatch 264 | import datetime 265 | cw = boto.ec2.cloudwatch.connect_to_region(self.loader.aws_region, 266 | aws_access_key_id=self.loader.aws_key, 267 | aws_secret_access_key=self.loader.aws_secret) 268 | stats = cw.get_metric_statistics( 269 | 300, 270 | datetime.datetime.utcnow() - datetime.timedelta(seconds=3200), 271 | datetime.datetime.utcnow(), 272 | 'CPUUtilization', 273 | 'AWS/EC2', 274 | 'Average', 275 | dimensions={'InstanceId':[instance.id]} 276 | ) 277 | 278 | stats = sorted(stats, key=lambda x: x['Timestamp']) 279 | 280 | import plotly 281 | from plotly.graph_objs import Scatter, Layout 282 | plotly.offline.plot({ 283 | "data": [ 284 | Scatter(x=[i['Timestamp'] for k, i in enumerate(stats)], y=[i['Average'] for i in stats]) 285 | ], 286 | "layout": Layout( 287 | title="CPU - %s" % instance.id, 288 | yaxis=dict(range=[0, 100]) 289 | ) 290 | }) 291 | 292 | return "echo see the browser\n" 293 | 294 | def cmd_INFO(self, line): 295 | instance_info = """ 296 | id 297 | groups - A list of Group objects representing the security groups associated with the instance. 298 | public_dns_name - The public dns name of the instance. 299 | private_dns_name - The private dns name of the instance. 300 | state - The string representation of the instance’s current state. 301 | state_code - An integer representation of the instance’s current state. 302 | previous_state - The string representation of the instance’s previous state. 303 | previous_state_code - An integer representation of the instance’s current state. 304 | key_name - The name of the SSH key associated with the instance. 305 | instance_type - The type of instance (e.g. m1.small). 306 | launch_time - The time the instance was launched. 307 | image_id - The ID of the AMI used to launch this instance. 308 | placement - The availability zone in which the instance is running. 309 | placement_group - The name of the placement group the instance is in (for cluster compute instances). 310 | placement_tenancy - The tenancy of the instance, if the instance is running within a VPC. An instance with a tenancy of dedicated runs on a single-tenant hardware. 311 | kernel - The kernel associated with the instance. 312 | ramdisk - The ramdisk associated with the instance. 313 | architecture - The architecture of the image (i386|x86_64). 314 | hypervisor - The hypervisor used. 315 | virtualization_type - The type of virtualization used. 316 | product_codes - A list of product codes associated with this instance. 317 | ami_launch_index - This instances position within it’s launch group. 318 | monitored - A boolean indicating whether monitoring is enabled or not. 319 | monitoring_state - A string value that contains the actual value of the monitoring element returned by EC2. 320 | spot_instance_request_id - The ID of the spot instance request if this is a spot instance. 321 | subnet_id - The VPC Subnet ID, if running in VPC. 322 | vpc_id - The VPC ID, if running in VPC. 323 | private_ip_address - The private IP address of the instance. 324 | ip_address - The public IP address of the instance. 325 | platform - Platform of the instance (e.g. Windows) 326 | root_device_name - The name of the root device. 327 | root_device_type - The root device type (ebs|instance-store). 328 | block_device_mapping - The Block Device Mapping for the instance. 329 | state_reason - The reason for the most recent state transition. 330 | interfaces - List of Elastic Network Interfaces associated with this instance. 331 | ebs_optimized - Whether instance is using optimized EBS volumes or not. 332 | instance_profile - A Python dict containing the instance profile id and arn associated with this instance. 333 | """ 334 | 335 | ret = [] 336 | our_instance = self.get_instance_by_public_ip(line) 337 | for ln in instance_info.split("\n"): 338 | if ln: 339 | kv = ln.split('-') 340 | k = kv[0].strip() 341 | if k: 342 | ret.append(u"%s: %s" % (k.decode('utf8'), getattr(our_instance, k, ''))) 343 | return "%s\n" % '\n'.join(ret) 344 | 345 | def assh(): 346 | parser = argparse.ArgumentParser() 347 | 348 | parser.add_argument("-o", "--out", type=str, 349 | help="output to file") 350 | 351 | parser.add_argument("-F", "--filter-tag", 352 | nargs='+', 353 | help="filter by tags eg: --filter-tag=Name:app1") 354 | 355 | parser.add_argument('-N', '--filter-name', help="filter by tag Name") 356 | 357 | parser.add_argument("-d", "--debug", 358 | help="debug mode - shows scores etc.") 359 | 360 | parser.add_argument("-i", "--input", 361 | help="input file") 362 | 363 | parser.add_argument("-e", "--eval", 364 | help="evaluate command output") 365 | 366 | parser.add_argument("-p", "--pipe-out", action='store_true', 367 | help="just echo the selected command, useful for pipe out") 368 | 369 | parser.add_argument("-I", "--separator", 370 | default=',', 371 | help="seperator in for multiple selection - ie. to join selected lines with ; etc.") 372 | 373 | parser.add_argument("-r", "--replace", 374 | default=' ', 375 | help="replace with this in eval string. ie. hst -r '__' --eval='cd __ && ls'") 376 | 377 | parser.add_argument("-l", "--logfile", 378 | default='assh.log', 379 | help="where to put log file in debug mode") 380 | 381 | parser.add_argument("account", type=str, 382 | help="aws account") 383 | # 384 | parser.add_argument("command", type=str, nargs='?', 385 | help="command - eg. ssh, fab") 386 | 387 | 388 | parser.add_argument('rest', nargs=argparse.REMAINDER) 389 | 390 | 391 | args = parser.parse_args() 392 | 393 | if args.debug: 394 | logger.setLevel(logging.DEBUG) 395 | hdlr = logging.FileHandler(args.logfile) 396 | logger.addHandler(hdlr) 397 | else: 398 | logger.setLevel(logging.CRITICAL) 399 | 400 | settings = imp.load_source('settings', '%s/.assh/%s.py' % (os.path.expanduser('~'), args.account)) 401 | 402 | AsshPicker.settings = settings 403 | 404 | tags = {} 405 | if args.filter_tag: 406 | for n in args.filter_tag: 407 | k, v = n.split(':') 408 | tags[k] = v 409 | 410 | if args.filter_name: 411 | tags['Name'] = args.filter_name 412 | 413 | loader = SimpleLineLoader( 414 | settings.AWS_REGION, 415 | settings.AWS_ACCESS_KEY_ID, 416 | settings.AWS_SECRET_ACCESS_KEY, 417 | tags=tags) 418 | 419 | lines = loader.load() 420 | 421 | if args.command=='list': 422 | for n in lines: 423 | print n 424 | return 425 | 426 | if len(lines) == 1: 427 | # no need to select anything... 428 | picker = AsshPicker(args=args) 429 | fn = picker.get_cmd_fn(args.command) 430 | line = fn(lines[0].split('|')[0].strip()) 431 | picker.write_output(line) 432 | return 433 | 434 | main(args, 435 | picker_cls=AsshPicker, 436 | loader=loader) 437 | 438 | if __name__ == '__main__': 439 | assh() --------------------------------------------------------------------------------