├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── ish ├── contrib ├── example.gif ├── example.json └── ish-autocomplete ├── ish.rb ├── ish ├── __init__.py ├── aws.py ├── cache.py ├── test_aws.py ├── test_cache.py ├── test_ui.py └── ui.py ├── requirements.txt ├── setup.py └── test.expect /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | \#* 3 | dist 4 | build 5 | *.egg-info 6 | .coverage 7 | bash-completion* 8 | wget-log 9 | .venv 10 | venv 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "nightly" 8 | 9 | addons: 10 | apt: 11 | packages: 12 | - expect 13 | - zsh 14 | 15 | install: pip install -r requirements.txt --no-use-wheel 16 | 17 | script: 18 | - coverage run --source ish -m unittest discover 19 | - coverage report -m --fail-under=100 20 | - ./test.expect 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Graham Christensen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `ish` 2 | 3 | ![example](./contrib/example.gif) 4 | 5 | ## Instance-SSH ...ish. 6 | 7 | [![Build Status](https://travis-ci.org/grahamc/ish.svg?branch=master)](https://travis-ci.org/grahamc/ish) 8 | 9 | `ish` connects you to a server in your AWS account based on properties of the 10 | server. Notably useful when you don't care which server *exactly*, just a 11 | server of a particular type. 12 | 13 | Currently you can connect to servers based on their: 14 | 15 | - name tag 16 | - environment tag 17 | - instance id 18 | - autoscaling group membership 19 | - opsworks instance name (oin) 20 | - opsworks stack name (ois) 21 | - image id (AMI) 22 | 23 | If more than one server matches the attribute, it will pick one and connect 24 | you to it. 25 | 26 | If you pass additional parameters, they will get appended to the ssh command. 27 | 28 | In the example command, `/usr/bin/ssh ip_address echo "hello_there"` is the 29 | run command: 30 | 31 | ``` 32 | $ ish name:openvpn echo "hello there" 33 | hello there 34 | ``` 35 | 36 | `ish` also supports autocompletion (read below.) 37 | 38 | ## Installation 39 | 40 | You can install via: 41 | 42 | - setuptools using `python3 setup.py install` and source the `contrib/ish-autocomplete` script. 43 | - Homebrew using `brew install https://raw.githubusercontent.com/grahamc/ish/master/ish.rb` 44 | 45 | 46 | ## Details 47 | 48 | ### Caching 49 | 50 | To improve performance, instance metadata is stored in `$HOME/.ish.json`. This 51 | file should be automatically replaced if it is over 60 seconds old, but delete 52 | it if you experience issues. 53 | 54 | ### AWS Configuration 55 | 56 | `ish` uses `boto3` which uses the standard AWS configuration stack. Read more 57 | here: https://boto3.readthedocs.org/en/latest/guide/configuration.html#configuration-sources 58 | 59 | ### SSH Configuration 60 | 61 | The command run is `ssh IP`. If you want to set additional configuration 62 | settings, please use your `~/.ssh/config`. 63 | 64 | An example SSH configuration might be 65 | 66 | ``` 67 | Host 172.* 68 | StrictHostKeyChecking no 69 | IdentityFile ~/.ssh/keys/aws-keys 70 | User yourusername 71 | ``` 72 | 73 | #### `ish` connects to the private instance IP 74 | 75 | You might find this article helpful if you use a bastion / jump-host instead 76 | of a VPN: http://edgeofsanity.net/article/2012/10/15/ssh-leap-frog.html 77 | 78 | ### Autocomplete 79 | 80 | A full list of supported targets can be found with `ish --completion`, and a 81 | bash and zsh compatible autocomplete script is found in `./contrib/`. 82 | 83 | #### Auto-completing targets with spaces in them 84 | 85 | Targets with spaces in them must be quoted, but will be autocompleted without 86 | them. Example: 87 | 88 | ``` 89 | $ ish name:Logstash Ingestion 90 | ``` 91 | 92 | ## Contributing 93 | 94 | - `flake8` must pass with no exceptions 95 | - `coverage` must report 100% coverage 96 | - it must run perfectly on python 3, python 2 is not supported. 97 | -------------------------------------------------------------------------------- /bin/ish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import ish.aws 4 | import ish.cache 5 | import ish.ui 6 | import sys 7 | import os 8 | 9 | cache_file = os.path.join( 10 | os.environ.get('HOME'), 11 | '.ish.json' 12 | ) 13 | 14 | options = ish.cache.load_or(cache_file, ish.aws.targets) 15 | 16 | input_handler = ish.ui.InputHandler(sys.argv, options) 17 | input_handler.handle_argv() 18 | sys.exit(128) 19 | -------------------------------------------------------------------------------- /contrib/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grahamc/ish/7edd7665edf8e33e6847d891d2ff519b492b32dc/contrib/example.gif -------------------------------------------------------------------------------- /contrib/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": ["127.0.0.1"], 3 | "name:abc": ["127.0.0.2"], 4 | "name:abc 123": ["127.0.0.3"] 5 | } 6 | -------------------------------------------------------------------------------- /contrib/ish-autocomplete: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$ZSH_VERSION" ]; then 4 | autoload -U compinit 5 | compinit 6 | 7 | _ish_complete() { 8 | completions="$(ish --completion)" 9 | reply=( "${(ps:\n:)completions}" ) 10 | } 11 | 12 | compctl -K _ish_complete ish 13 | elif [ -n "BASH_VERSION" ]; then 14 | _ish_complete() { 15 | local IFS=$'\n' 16 | 17 | local cur prev opts 18 | _get_comp_words_by_ref -n : cur 19 | 20 | words=$(ish --completion) 21 | COMPREPLY=( $(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}") ) 22 | __ltrim_colon_completions "$cur" 23 | return 0 24 | } 25 | 26 | complete -F _ish_complete ish 27 | fi 28 | -------------------------------------------------------------------------------- /ish.rb: -------------------------------------------------------------------------------- 1 | class Ish < Formula 2 | url "https://github.com/grahamc/ish.git", :ref => 'master' 3 | version '0.1.3' 4 | 5 | depends_on :python3 6 | 7 | resource "boto3" do 8 | url "https://pypi.python.org/packages/source/b/boto3/boto3-1.1.3.tar.gz" 9 | sha256 "10b5d92ce79366425e35af0b79b001b8ebc38c5fca6c7742885f6b8f87d06665" 10 | end 11 | 12 | resource "botocore" do 13 | url "https://pypi.python.org/packages/source/b/botocore/botocore-1.2.4.tar.gz" 14 | sha256 "6330dec53831e4f961e2503a4d9bfe9e790e1e7ac716f8edc07f1b37ff2765da" 15 | end 16 | 17 | resource "docutils" do 18 | url "https://pypi.python.org/packages/source/d/docutils/docutils-0.12.tar.gz" 19 | sha256 "c7db717810ab6965f66c8cf0398a98c9d8df982da39b4cd7f162911eb89596fa" 20 | end 21 | 22 | resource "futures" do 23 | url "https://pypi.python.org/packages/source/f/futures/futures-2.2.0.tar.gz" 24 | sha256 "151c057173474a3a40f897165951c0e33ad04f37de65b6de547ddef107fd0ed3" 25 | end 26 | 27 | resource "jmespath" do 28 | url "https://pypi.python.org/packages/source/j/jmespath/jmespath-0.7.1.tar.gz" 29 | sha256 "cd5a12ee3dfa470283a020a35e69e83b0700d44fe413014fd35ad5584c5f5fd1" 30 | end 31 | 32 | resource "python-dateutil" do 33 | url "https://pypi.python.org/packages/source/p/python-dateutil/python-dateutil-2.4.2.tar.gz" 34 | sha256 "3e95445c1db500a344079a47b171c45ef18f57d188dffdb0e4165c71bea8eb3d" 35 | end 36 | 37 | resource "six" do 38 | url "https://pypi.python.org/packages/source/s/six/six-1.9.0.tar.gz" 39 | sha256 "e24052411fc4fbd1f672635537c3fc2330d9481b18c0317695b46259512c91d5" 40 | end 41 | 42 | def install 43 | version = Language::Python.major_minor_version "python3" 44 | ENV.prepend_create_path "PYTHONPATH", libexec/"vendor/lib/python#{version}/site-packages" 45 | 46 | 47 | %w[boto3 botocore docutils futures jmespath python-dateutil six].each do |r| 48 | resource(r).stage do 49 | system "python3", *Language::Python.setup_install_args(libexec/"vendor") 50 | end 51 | end 52 | ENV.prepend_create_path "PYTHONPATH", libexec/"lib/python#{version}/site-packages" 53 | system "python3", *Language::Python.setup_install_args(libexec) 54 | 55 | bin.install Dir[libexec/"bin/*"] 56 | bin.env_script_all_files(libexec/"bin", :PYTHONPATH => ENV["PYTHONPATH"]) 57 | etc.install Dir["contrib/ish-autocomplete"] 58 | end 59 | 60 | def caveats 61 | "Source #{etc}/ish-autocomplete for autocompletion" 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /ish/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grahamc/ish/7edd7665edf8e33e6847d891d2ff519b492b32dc/ish/__init__.py -------------------------------------------------------------------------------- /ish/aws.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import boto3 3 | 4 | 5 | def get_instances(): 6 | return boto3.resource('ec2').instances.all() 7 | 8 | 9 | def get_running_instances(ec2_instances): 10 | return [instance for instance in ec2_instances 11 | if instance.state['Name'] == 'running'] 12 | 13 | 14 | def id_targets_for_instances(instances): 15 | return [(inst.id, [inst.private_ip_address]) for inst in instances] 16 | 17 | 18 | def name_tag_targets_for_instances(ilist): 19 | tag_nodes = {} 20 | 21 | for i in ilist: 22 | if i.tags is None: 23 | continue 24 | for tag in i.tags: 25 | if tag['Key'] == 'Name': 26 | name = tag['Value'] 27 | if name not in tag_nodes: 28 | tag_nodes[name] = [] 29 | tag_nodes[name].append(i.private_ip_address) 30 | 31 | return [('name:{}'.format(tag), ips) for tag, ips in tag_nodes.items()] 32 | 33 | 34 | def environment_tag_targets_for_instances(ilist): 35 | """ 36 | Generate List of possible targets ordered by environment key. 37 | """ 38 | env_nodes = {} 39 | 40 | for i in ilist: 41 | if i.tags is None: 42 | continue 43 | for tag in i.tags: 44 | if tag['Key'] == 'environment' or tag['Key'] == 'Environment': 45 | name = tag['Value'] 46 | if name not in env_nodes: 47 | env_nodes[name] = [] 48 | env_nodes[name].append(i.private_ip_address) 49 | 50 | return [('env:{}'.format(env), ips) for env, ips in env_nodes.items()] 51 | 52 | 53 | def asg_targets_for_instances(ilist): 54 | asg_nodes = {} 55 | 56 | for i in ilist: 57 | if i.tags is None: 58 | continue 59 | for tag in i.tags: 60 | if tag['Key'] == 'aws:autoscaling:groupName': 61 | name = tag['Value'] 62 | if name not in asg_nodes: 63 | asg_nodes[name] = [] 64 | asg_nodes[name].append(i.private_ip_address) 65 | 66 | return [('asg:{}'.format(asg), ips) for asg, ips in asg_nodes.items()] 67 | 68 | 69 | def opsworks_instance_name_targets_for_instances(ilist): 70 | """ 71 | Generate targets list by opwsorks instance name 72 | """ 73 | oin_nodes = {} 74 | 75 | for i in ilist: 76 | if i.tags is None: 77 | continue 78 | for tag in i.tags: 79 | if tag['Key'] == 'opsworks:instance': 80 | name = tag['Value'] 81 | if name not in oin_nodes: 82 | oin_nodes[name] = [] 83 | oin_nodes[name].append(i.private_ip_address) 84 | 85 | return [('oin:{}'.format(oin), ips) for oin, ips in oin_nodes.items()] 86 | 87 | 88 | def opsworks_instance_stack_targets_for_instances(ilist): 89 | """ 90 | Generate targets list by opwsorks stack name 91 | """ 92 | ois_nodes = {} 93 | 94 | for i in ilist: 95 | if i.tags is None: 96 | continue 97 | for tag in i.tags: 98 | if tag['Key'] == 'opsworks:stack': 99 | name = tag['Value'] 100 | if name not in ois_nodes: 101 | ois_nodes[name] = [] 102 | ois_nodes[name].append(i.private_ip_address) 103 | 104 | return [('ois:{}'.format(ois), ips) for ois, ips in ois_nodes.items()] 105 | 106 | 107 | def ami_targets_for_instances(ilist): 108 | ami_nodes = {} 109 | 110 | for i in ilist: 111 | if i.image_id not in ami_nodes: 112 | ami_nodes[i.image_id] = [] 113 | ami_nodes[i.image_id].append(i.private_ip_address) 114 | 115 | return [(ami, ips) for ami, ips in ami_nodes.items()] 116 | 117 | 118 | def targets_for_instances(_instances): 119 | """ 120 | Aggregate all possible instance mappings 121 | """ 122 | by_id = id_targets_for_instances(_instances) 123 | by_image = ami_targets_for_instances(_instances) 124 | by_name_tag = name_tag_targets_for_instances(_instances) 125 | by_environment_tag = environment_tag_targets_for_instances(_instances) 126 | by_asg = asg_targets_for_instances(_instances) 127 | by_oin = opsworks_instance_name_targets_for_instances(_instances) 128 | by_ois = opsworks_instance_stack_targets_for_instances(_instances) 129 | 130 | return dict( 131 | by_id + 132 | by_name_tag + 133 | by_environment_tag + 134 | by_image + 135 | by_asg + 136 | by_ois + 137 | by_oin 138 | ) 139 | 140 | 141 | def targets(): 142 | return targets_for_instances(get_running_instances(get_instances())) 143 | -------------------------------------------------------------------------------- /ish/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import json 4 | 5 | 6 | def needs_new(cache_file, max_age): 7 | try: 8 | mod_time = os.path.getmtime(cache_file) 9 | now = int(time.time()) 10 | return (now - mod_time) > max_age 11 | 12 | except OSError: 13 | return True 14 | 15 | 16 | def load_or(cache_file, otherwise, max_age=60): 17 | if not needs_new(cache_file, max_age): 18 | with open(cache_file) as cf: 19 | try: 20 | return json.loads(cf.read()) 21 | except ValueError: 22 | pass 23 | 24 | with open(cache_file, 'w') as cf: 25 | new_data = otherwise() 26 | cf.write(json.dumps(new_data)) 27 | return new_data 28 | -------------------------------------------------------------------------------- /ish/test_aws.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import ish.aws 4 | from unittest.mock import Mock, patch 5 | 6 | 7 | class StubInstance: 8 | def __init__(self, **kwargs): 9 | for k, v in kwargs.items(): 10 | setattr(self, k, v) 11 | 12 | 13 | class TestAws(unittest.TestCase): 14 | def assertItemsEqual(self, a, b): 15 | self.assertEqual(sorted(a), sorted(b)) 16 | 17 | @patch('boto3.resource') 18 | def test_get_instances(self, botoresource): 19 | instances = [ 20 | StubInstance(id=1, state={'Name': 'running'}), 21 | StubInstance(id=2, state={'Name': 'notrunning'}) 22 | ] 23 | 24 | ec2 = Mock() 25 | ec2.instances.all = Mock(return_value=instances) 26 | botoresource.return_value = ec2 27 | 28 | self.assertEqual(instances, ish.aws.get_instances()) 29 | 30 | def test_get_running_instances(self): 31 | instances = [ 32 | StubInstance(id=1, state={'Name': 'running'}), 33 | StubInstance(id=2, state={'Name': 'notrunning'}) 34 | ] 35 | self.assertItemsEqual( 36 | [instances[0]], 37 | ish.aws.get_running_instances(instances), 38 | ) 39 | 40 | def test_id_targets_for_instances(self): 41 | instances = [ 42 | StubInstance(id=1, private_ip_address='1.2.3.4'), 43 | StubInstance(id=2, private_ip_address='2.3.4.5') 44 | ] 45 | 46 | self.assertEqual( 47 | [ 48 | (1, ['1.2.3.4']), 49 | (2, ['2.3.4.5']) 50 | ], 51 | ish.aws.id_targets_for_instances(instances) 52 | ) 53 | 54 | def test_name_tag_targets_for_instances(self): 55 | instances = [ 56 | StubInstance(private_ip_address='0.0.0.0', tags=None), 57 | StubInstance(private_ip_address='1.2.3.4', tags=[ 58 | {'Key': 'foo', 'Value': 'bar'}, 59 | {'Key': 'Name', 'Value': 'myname'} 60 | ]), 61 | StubInstance(private_ip_address='2.3.4.5', tags=[ 62 | {'Key': 'foo', 'Value': 'baz'}, 63 | {'Key': 'Name', 'Value': 'myname'} 64 | ]), 65 | StubInstance(private_ip_address='3.4.5.6', tags=[ 66 | {'Key': 'foo', 'Value': 'baz'}, 67 | {'Key': 'Name', 'Value': 'newname'} 68 | ]), 69 | ] 70 | 71 | self.assertItemsEqual( 72 | [ 73 | ('name:myname', ['1.2.3.4', '2.3.4.5']), 74 | ('name:newname', ['3.4.5.6']) 75 | ], 76 | ish.aws.name_tag_targets_for_instances(instances) 77 | ) 78 | 79 | def test_environment_tag_targets_for_instances(self): 80 | instances = [ 81 | StubInstance(private_ip_address='0.0.0.0', tags=None), 82 | StubInstance(private_ip_address='1.2.3.4', tags=[ 83 | {'Key': 'foo', 'Value': 'bar'}, 84 | {'Key': 'environment', 'Value': 'live1'} 85 | ]), 86 | StubInstance(private_ip_address='2.3.4.5', tags=[ 87 | {'Key': 'foo', 'Value': 'baz'}, 88 | {'Key': 'Environment', 'Value': 'live1'} 89 | ]), 90 | StubInstance(private_ip_address='3.4.5.6', tags=[ 91 | {'Key': 'foo', 'Value': 'baz'}, 92 | {'Key': 'environment', 'Value': 'test1'} 93 | ]), 94 | ] 95 | 96 | self.assertItemsEqual( 97 | [ 98 | ('env:live1', ['1.2.3.4', '2.3.4.5']), 99 | ('env:test1', ['3.4.5.6']) 100 | ], 101 | ish.aws.environment_tag_targets_for_instances(instances) 102 | ) 103 | 104 | def test_asg_targets_for_instances(self): 105 | instances = [ 106 | StubInstance(private_ip_address='0.0.0.0', tags=None), 107 | StubInstance(private_ip_address='1.2.3.4', tags=[ 108 | {'Key': 'foo', 'Value': 'bar'}, 109 | {'Key': 'aws:autoscaling:groupName', 'Value': 'foogroup'} 110 | ]), 111 | StubInstance(private_ip_address='2.3.4.5', tags=[ 112 | {'Key': 'foo', 'Value': 'baz'}, 113 | {'Key': 'aws:autoscaling:groupName', 'Value': 'bargroup'} 114 | ]), 115 | StubInstance(private_ip_address='3.4.5.6', tags=[ 116 | {'Key': 'foo', 'Value': 'baz'}, 117 | {'Key': 'aws:autoscaling:groupName', 'Value': 'foogroup'} 118 | ]), 119 | ] 120 | 121 | self.assertItemsEqual( 122 | [ 123 | ('asg:foogroup', ['1.2.3.4', '3.4.5.6']), 124 | ('asg:bargroup', ['2.3.4.5']) 125 | ], 126 | ish.aws.asg_targets_for_instances(instances) 127 | ) 128 | 129 | def test_opsworks_instance_name_targets_for_instances(self): 130 | instances = [ 131 | StubInstance(private_ip_address='0.0.0.0', tags=None), 132 | StubInstance(private_ip_address='1.2.3.4', tags=[ 133 | {'Key': 'foo', 'Value': 'bar'}, 134 | {'Key': 'opsworks:instance', 'Value': 'frontend1'} 135 | ]), 136 | StubInstance(private_ip_address='2.3.4.5', tags=[ 137 | {'Key': 'foo', 'Value': 'baz'}, 138 | {'Key': 'opsworks:instance', 'Value': 'frontend2'} 139 | ]), 140 | ] 141 | 142 | self.assertItemsEqual( 143 | [ 144 | ('oin:frontend1', ['1.2.3.4']), 145 | ('oin:frontend2', ['2.3.4.5']) 146 | ], 147 | ish.aws.opsworks_instance_name_targets_for_instances(instances) 148 | ) 149 | 150 | def test_opsworks_instance_stack_targets_for_instances(self): 151 | instances = [ 152 | StubInstance(private_ip_address='0.0.0.0', tags=None), 153 | StubInstance(private_ip_address='1.2.3.4', tags=[ 154 | {'Key': 'foo', 'Value': 'bar'}, 155 | {'Key': 'opsworks:stack', 'Value': 'stack1'} 156 | ]), 157 | StubInstance(private_ip_address='2.3.4.5', tags=[ 158 | {'Key': 'foo', 'Value': 'baz'}, 159 | {'Key': 'opsworks:stack', 'Value': 'stack1'} 160 | ]), 161 | StubInstance(private_ip_address='3.4.5.6', tags=[ 162 | {'Key': 'foo', 'Value': 'baz'}, 163 | {'Key': 'opsworks:stack', 'Value': 'stack2'} 164 | ]), 165 | ] 166 | 167 | self.assertItemsEqual( 168 | [ 169 | ('ois:stack1', ['1.2.3.4', '2.3.4.5']), 170 | ('ois:stack2', ['3.4.5.6']) 171 | ], 172 | ish.aws.opsworks_instance_stack_targets_for_instances(instances) 173 | ) 174 | 175 | def test_ami_targets_for_instances(self): 176 | instances = [ 177 | StubInstance(private_ip_address='1.2.3.4', image_id='ami-123'), 178 | StubInstance(private_ip_address='2.3.4.5', image_id='ami-123'), 179 | StubInstance(private_ip_address='3.4.5.6', image_id='ami-456') 180 | ] 181 | 182 | self.assertItemsEqual( 183 | [ 184 | ('ami-123', ['1.2.3.4', '2.3.4.5']), 185 | ('ami-456', ['3.4.5.6']) 186 | 187 | ], 188 | ish.aws.ami_targets_for_instances(instances) 189 | ) 190 | 191 | def test_targets_for_instances(self): 192 | instances = [ 193 | StubInstance( 194 | id='i-abc123', 195 | image_id='ami-def456', 196 | private_ip_address='1.2.3.4', 197 | tags=[ 198 | {'Key': 'foo', 'Value': 'bar'}, 199 | {'Key': 'Name', 'Value': 'servername'}, 200 | {'Key': 'Environment', 'Value': 'live1'}, 201 | {'Key': 'aws:autoscaling:groupName', 'Value': 'groupname'}, 202 | {'Key': 'opsworks:instance', 'Value': 'frontend1'}, 203 | {'Key': 'opsworks:stack', 'Value': 'stack1'} 204 | ] 205 | 206 | ) 207 | ] 208 | 209 | self.assertEqual( 210 | { 211 | 'name:servername': ['1.2.3.4'], 212 | 'asg:groupname': ['1.2.3.4'], 213 | 'i-abc123': ['1.2.3.4'], 214 | 'ami-def456': ['1.2.3.4'], 215 | 'env:live1': ['1.2.3.4'], 216 | 'oin:frontend1': ['1.2.3.4'], 217 | 'ois:stack1': ['1.2.3.4'], 218 | }, 219 | ish.aws.targets_for_instances(instances) 220 | ) 221 | 222 | @patch('boto3.resource') 223 | def test_targets(self, botoresource): 224 | instances = [ 225 | StubInstance( 226 | id='i-abc123', 227 | image_id='ami-def456', 228 | private_ip_address='1.2.3.4', 229 | state={'Name': 'running'}, 230 | tags=[ 231 | {'Key': 'foo', 'Value': 'bar'}, 232 | {'Key': 'Name', 'Value': 'servername'}, 233 | {'Key': 'Environment', 'Value': 'live1'}, 234 | {'Key': 'aws:autoscaling:groupName', 'Value': 'groupname'}, 235 | {'Key': 'opsworks:instance', 'Value': 'frontend1'}, 236 | {'Key': 'opsworks:stack', 'Value': 'stack1'} 237 | ] 238 | ), 239 | StubInstance(id=2, state={'Name': 'notrunning'}) 240 | ] 241 | 242 | ec2 = Mock() 243 | ec2.instances.all = Mock(return_value=instances) 244 | botoresource.return_value = ec2 245 | 246 | self.assertEqual( 247 | { 248 | 'name:servername': ['1.2.3.4'], 249 | 'asg:groupname': ['1.2.3.4'], 250 | 'i-abc123': ['1.2.3.4'], 251 | 'ami-def456': ['1.2.3.4'], 252 | 'env:live1': ['1.2.3.4'], 253 | 'oin:frontend1': ['1.2.3.4'], 254 | 'ois:stack1': ['1.2.3.4'], 255 | }, 256 | ish.aws.targets() 257 | ) 258 | -------------------------------------------------------------------------------- /ish/test_cache.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import ish.cache 4 | import os 5 | import tempfile 6 | from unittest.mock import Mock 7 | 8 | 9 | class TestCache(unittest.TestCase): 10 | def test_needs_new_file_missing(self): 11 | self.assertTrue(ish.cache.needs_new('/this/file/does/not/exist', 60)) 12 | 13 | def test_needs_new_file_old(self): 14 | tf = tempfile.NamedTemporaryFile() 15 | os.utime(tf.name, (0, 0)) 16 | self.assertTrue(ish.cache.needs_new(tf.name, 60)) 17 | 18 | def test_needs_new_file_new(self): 19 | tf = tempfile.NamedTemporaryFile() 20 | os.utime(tf.name, None) 21 | self.assertFalse(ish.cache.needs_new(tf.name, 60)) 22 | 23 | def test_load_or_old_file(self): 24 | tf = tempfile.NamedTemporaryFile() 25 | os.utime(tf.name, (0, 0)) 26 | stub = Mock(return_value={'foo': 'bar'}) 27 | ish.cache.load_or(tf.name, stub) 28 | stub.assert_called_with() 29 | 30 | def test_load_or_new_valid_file(self): 31 | tf = tempfile.NamedTemporaryFile() 32 | tf.write(bytes('{"foo": "bar"}', 'UTF-8')) 33 | tf.seek(0) 34 | stub = Mock(return_value={'foo': 'bar'}) 35 | self.assertEqual( 36 | {'foo': 'bar'}, 37 | ish.cache.load_or(tf.name, stub) 38 | ) 39 | self.assertFalse(stub.called) 40 | 41 | def test_load_or_new_invalid_file(self): 42 | tf = tempfile.NamedTemporaryFile() 43 | tf.write(bytes('{"foo": "ba', 'UTF-8')) 44 | tf.seek(0) 45 | stub = Mock(return_value={'foo': 'bar'}) 46 | self.assertEqual( 47 | {'foo': 'bar'}, 48 | ish.cache.load_or(tf.name, stub) 49 | ) 50 | self.assertTrue(stub.called) 51 | -------------------------------------------------------------------------------- /ish/test_ui.py: -------------------------------------------------------------------------------- 1 | 2 | import io 3 | import ish.ui 4 | import unittest 5 | from unittest.mock import Mock 6 | 7 | 8 | class TestUi(unittest.TestCase): 9 | def setUp(self): 10 | self.err_mock = io.StringIO() 11 | self.out_mock = io.StringIO() 12 | self.exit_mock = Mock() 13 | 14 | def test_stderr(self): 15 | view = ish.ui.View('stub', err=self.err_mock) 16 | view.stderr('test') 17 | 18 | self.assertEqual("test\n", self.err_mock.getvalue()) 19 | 20 | def test_stdout(self): 21 | view = ish.ui.View('stub', out=self.out_mock) 22 | view.stdout('test') 23 | 24 | self.assertEqual("test\n", self.out_mock.getvalue()) 25 | 26 | def test_help(self): 27 | view = ish.ui.View('foo', err=self.err_mock, exit=self.exit_mock) 28 | view.help() 29 | 30 | self.assertEqual( 31 | "Usage: foo target [ssh parameters]\n", 32 | self.err_mock.getvalue() 33 | ) 34 | self.exit_mock.assert_called_with(1) 35 | 36 | def test_valid_targets(self): 37 | view = ish.ui.View('stub', out=self.out_mock, exit=self.exit_mock) 38 | view.valid_targets(['foo', 'bar']) 39 | self.assertEqual( 40 | "bar\nfoo\n", 41 | self.out_mock.getvalue() 42 | 43 | ) 44 | self.exit_mock.assert_called_with(0) 45 | 46 | def test_invalid_target(self): 47 | view = ish.ui.View('stub', err=self.err_mock, exit=self.exit_mock) 48 | view.invalid_target('invalid') 49 | self.assertEqual("No target named invalid\n", self.err_mock.getvalue()) 50 | 51 | def test_connect_to(self): 52 | execvp = Mock() 53 | view = ish.ui.View( 54 | 'stub', 55 | err=self.err_mock, 56 | exit=self.exit_mock, 57 | execvp=execvp 58 | ) 59 | 60 | view.connect_to('1.2.3.4', ['1.2.3.4', '2.3.4.5'], 'serverA') 61 | 62 | self.assertEqual( 63 | "Found 2 IPs for serverA:\n" 64 | " - 1.2.3.4 (selected)\n" 65 | " - 2.3.4.5\n", 66 | self.err_mock.getvalue() 67 | ) 68 | execvp.assert_called_with( 69 | '/usr/bin/ssh', 70 | [ 71 | '/usr/bin/ssh', 72 | '1.2.3.4' 73 | ] 74 | ) 75 | self.exit_mock.assert_called_with(2) 76 | 77 | 78 | class TestInputHandler(unittest.TestCase): 79 | def setUp(self): 80 | self.ui = Mock() 81 | self.ui_constructor = Mock(return_value=self.ui) 82 | 83 | def test_handle_argv_none(self): 84 | self.ui.help = Mock() 85 | 86 | ih = ish.ui.InputHandler(['ish'], {}, self.ui_constructor) 87 | self.ui_constructor.assert_called_with('ish') 88 | ih.handle_argv() 89 | self.ui.help.assert_called_with() 90 | 91 | def test_handle_argv_completion(self): 92 | ih = ish.ui.InputHandler( 93 | ['ish', '--completion'], 94 | {'foo': [], 'bar': []}, 95 | self.ui_constructor 96 | ) 97 | self.ui.valid_targets = Mock() 98 | ih.handle_argv() 99 | self.ui.valid_targets.assert_called_with({'foo': [], 'bar': []}) 100 | 101 | def test_handle_argv_invalid_target(self): 102 | ih = ish.ui.InputHandler( 103 | ['ish', 'invalid'], 104 | {'server1': [], 'server2': []}, 105 | self.ui_constructor 106 | ) 107 | self.ui.invalid_target = Mock() 108 | ih.handle_argv() 109 | self.ui.invalid_target.assert_called_with('invalid') 110 | 111 | def test_handle_argv_valid_target(self): 112 | ih = ish.ui.InputHandler( 113 | ['ish', 'valid'], 114 | {'valid': ['1.2.3.4']}, 115 | self.ui_constructor 116 | ) 117 | 118 | self.ui.connect_to = Mock() 119 | ih.handle_argv() 120 | self.ui.connect_to.assert_called_with('1.2.3.4', ['1.2.3.4'], 'valid') 121 | -------------------------------------------------------------------------------- /ish/ui.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import random 4 | 5 | 6 | class View: 7 | def __init__(self, caller, err=sys.stderr, out=sys.stdout, exit=sys.exit, 8 | execvp=os.execvp): 9 | self.caller = caller 10 | self.err = err 11 | self.out = out 12 | self.exit = exit 13 | self.execvp = execvp 14 | 15 | def stderr(self, msg): 16 | self.err.write("{}\n".format(msg)) 17 | 18 | def stdout(self, msg): 19 | self.out.write("{}\n".format(msg)) 20 | 21 | def help(self): 22 | self.stderr("Usage: {} target [ssh parameters]".format(self.caller)) 23 | self.exit(1) 24 | 25 | def valid_targets(self, targets): 26 | for key in sorted([key for key in targets]): 27 | self.stdout(key) 28 | 29 | self.exit(0) 30 | 31 | def invalid_target(self, attempted_target): 32 | self.stderr("No target named {}".format(attempted_target)) 33 | self.exit(1) 34 | 35 | def connect_to(self, selected_ip, all_ips, target): 36 | self.stderr("Found {} IPs for {}:".format(len(all_ips), target)) 37 | for ip in all_ips: 38 | if ip == selected_ip: 39 | self.stderr(" - {} (selected)".format(ip)) 40 | else: 41 | self.stderr(" - {}".format(ip)) 42 | 43 | cmd = '/usr/bin/ssh' 44 | arguments = [cmd, selected_ip] 45 | arguments.extend(sys.argv[2:]) 46 | 47 | self.execvp(cmd, arguments) 48 | self.exit(2) 49 | 50 | 51 | class InputHandler: 52 | def __init__(self, argv, targets, ui=View): 53 | self.argv = argv 54 | self.ui = ui(argv[0]) 55 | self.targets = targets 56 | 57 | def handle_argv(self): 58 | if len(self.argv) == 1: 59 | self.ui.help() 60 | else: 61 | arg1 = self.argv[1] 62 | 63 | if arg1 == '--completion': 64 | self.ui.valid_targets(self.targets) 65 | elif arg1 in self.targets: 66 | ips = sorted(self.targets[arg1]) 67 | selected_ip = random.choice(ips) 68 | self.ui.connect_to(selected_ip, ips, arg1) 69 | else: 70 | self.ui.invalid_target(arg1) 71 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | coverage 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | setup( 3 | name='ish', 4 | scripts=['bin/ish'], 5 | package_dir={'ish': 'ish'}, 6 | packages=find_packages(), 7 | install_requires=[ 8 | 'boto3>=1.4.3', 9 | 'jmespath>=0.9.0' 10 | ] 11 | ) 12 | -------------------------------------------------------------------------------- /test.expect: -------------------------------------------------------------------------------- 1 | #!/usr/bin/expect 2 | 3 | # \003 is Ctrl-C 4 | # \033 is right-arrow 5 | 6 | log_user 1 7 | 8 | spawn bash --norc 9 | expect "bash" 10 | send "export \"PS1=bash: \"\n" 11 | expect "bash:" 12 | 13 | send "test -f bash-completion-2.1.tar.bz2 || wget https://bash-completion.alioth.debian.org/files/bash-completion-2.1.tar.bz2\n" 14 | expect "bash:" 15 | send "test -d bash-completion-2.1 || tar -xf bash-completion-2.1.tar.bz2\n" 16 | expect "bash:" 17 | send "cd bash-comple\t\n" 18 | expect "bash:" 19 | send "./configure\n" 20 | expect "bash:" 21 | send "make\n" 22 | expect "bash:" 23 | send "source bash_completion\n" 24 | expect "bash:" 25 | send "cd ..\n" 26 | expect "bash:" 27 | 28 | send "source ./contrib/ish-autocomplete\n" 29 | expect "bash:" 30 | 31 | send "cat ./contrib/example.json > ~/.ish.json\n" 32 | expect "bash:" 33 | 34 | send "\n\n\n\n" 35 | expect "bash:" 36 | send "ish foo\t\t\t" 37 | expect "foo" 38 | send "\n" 39 | expect { 40 | "127.0.0.1" { send \003 } 41 | timeout { exit 2 } 42 | } 43 | 44 | send "\n\n\n\n" 45 | expect "bash:" 46 | send "ish name\t\t\t" 47 | expect "abc" 48 | send "\n" 49 | expect { 50 | "127.0.0.2" { send \003 } 51 | timeout { exit 3 } 52 | } 53 | 54 | send "ish \"name\t\t\t" 55 | expect "abc" 56 | send " \t" 57 | send "\n" 58 | expect { 59 | "127.0.0.3" { send \003 } 60 | timeoout { exit 4 } 61 | } 62 | 63 | close 64 | 65 | # force_conservative slows things down for zsh 66 | set force_conservative 1 67 | 68 | spawn zsh -f -d 69 | expect -exact "%" 70 | send "PS1=\"zsh: \"\n" 71 | expect "zsh:" 72 | send "\n" 73 | expect "zsh: " 74 | send "cat ./contrib/example.json > ~/.ish.json\n" 75 | expect "zsh: " 76 | send "source ./contrib/ish-autocomplete\n" 77 | expect "zsh:" 78 | 79 | send "\n\n\n\n" 80 | expect "zsh:" 81 | send "ish foo\t\t\t" 82 | expect "foo" 83 | send "\n" 84 | expect { 85 | "127.0.0.1" { send \003 } 86 | timeout { exit 5 } 87 | } 88 | 89 | send "\n\n\n\n" 90 | expect "zsh:" 91 | send "ish name\t\t\t" 92 | expect "abc" 93 | send "\n" 94 | expect { 95 | "127.0.0.2" { send \003 } 96 | timeout { exit 6 } 97 | } 98 | 99 | send "ish name:\t" 100 | expect "abc" 101 | send "\t\t\t" 102 | expect "123" 103 | send "\n" 104 | expect { 105 | "127.0.0.3" { send \003 } 106 | timeout { exit 7 } 107 | } 108 | --------------------------------------------------------------------------------