├── .gitignore ├── LICENSE ├── README.md ├── runin ├── DO.py ├── __init__.py ├── __main__.py └── runin.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.egg-info 4 | MANIFEST 5 | dist/ 6 | build/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 blha303 2 | 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and 7 | to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of 10 | the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 13 | THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 15 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 16 | DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DO-runin 2 | ======== 3 | 4 | [![PyPI version](https://badge.fury.io/py/DO_runin.svg)](https://badge.fury.io/py/DO_runin) 5 | 6 | A tool that starts a DigitalOcean droplet in a given region and runs a 7 | given command, displaying the output. Opens a shell if requested. 8 | Destroys the droplet upon command completion or shell closure. 9 | 10 | ![example](http://i.imgur.com/qfJ5pQJ.gif) 11 | -------------------------------------------------------------------------------- /runin/DO.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | input = raw_input if hasattr(__builtins__, "raw_input") else input 3 | import requests 4 | import time 5 | import uuid 6 | import sys 7 | import os 8 | import socket 9 | 10 | def get(TOKEN, endpoint): 11 | return requests.get("https://api.digitalocean.com/v2/{}".format(endpoint), 12 | headers={"Authorization": "Bearer {}".format(TOKEN)} 13 | ).json() 14 | 15 | def post(TOKEN, endpoint, json=None): 16 | return requests.post("https://api.digitalocean.com/v2/{}".format(endpoint), 17 | headers={"Authorization": "Bearer {}".format(TOKEN)}, 18 | json=json 19 | ).json() 20 | 21 | def delete(TOKEN, endpoint): 22 | return requests.delete("https://api.digitalocean.com/v2/{}".format(endpoint), 23 | headers={"Authorization": "Bearer {}".format(TOKEN)} 24 | ).status_code == requests.codes.no_content 25 | 26 | def get_token_from_file(): 27 | try: 28 | with open(os.path.expanduser("~/.DO-token")) as f: 29 | return f.read().strip() 30 | except (IOError, FileNotFoundError): 31 | return None 32 | 33 | def get_token(): 34 | try: 35 | return os.getenv("DO_TOKEN") or get_token_from_file() or input(os.path.expanduser( 36 | """Please set your DigitalOcean personal access token as either: 37 | * $DO_TOKEN 38 | * ~/.DO-token 39 | * or enter it below as a temporary measure 40 | Use Ctrl-C to exit : """)) 41 | except KeyboardInterrupt: 42 | print(file=sys.stderr) 43 | sys.exit(130) 44 | 45 | def get_regions(p=False): 46 | regions = get(get_token(), "/regions")["regions"] 47 | if p: 48 | _ = list(regions) 49 | for region in _: 50 | region["sizes"] = ",".join(sorted(region["sizes"])) 51 | region["features"] = "".join(s[0] for s in region["features"]) 52 | print("{slug}: {name} ({features}) \t{sizes}".format(**region) + ("\tX" if not region["available"] else ""), file=sys.stderr) 53 | return {d["slug"]: d for d in regions} 54 | 55 | def get_ssh_keys(p=False): 56 | keys = get(get_token(), "/account/keys")["ssh_keys"] 57 | if p: 58 | for key in keys: 59 | print("\t{id}: {name} ({fingerprint}):\n{public_key}".format(**key), file=sys.stderr) 60 | return keys 61 | 62 | def get_images(p=False): 63 | images = get(get_token(), "/images")["images"] 64 | if p: 65 | print("Public:", file=sys.stderr) 66 | for image in [i for i in images if i["public"]]: 67 | print("{id}: {distribution} {name} ({slug})".format(**image), file=sys.stderr) 68 | print("Private:", file=sys.stderr) 69 | for image in [i for i in images if not i["public"]]: 70 | print("{id}: {distribution} {name}".format(**image), file=sys.stderr) 71 | return images 72 | 73 | def get_action(id, p=False): 74 | i = 1 75 | while True: 76 | action = get(get_token(), "/actions/{}".format(id))["action"] 77 | if action["status"] == "completed": 78 | if action["resource_type"] == "droplet": 79 | break 80 | return True 81 | elif action["status"] == "errored": 82 | return False 83 | i = i+1 if i < 3 else 1 84 | if p: 85 | print("Waiting for action" + ("." * i) + (" " * (3-i)), end="\r", file=sys.stderr) 86 | time.sleep(3) 87 | droplet = get(get_token(), "/droplets/{}".format(action["resource_id"]))["droplet"] 88 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 89 | while True: 90 | i = i+1 if i < 3 else 1 91 | if p: 92 | print("Waiting for server to finish booting" + ("." * i) + (" " * (3-i)), file=sys.stderr, end="\r") 93 | if sock.connect_ex((droplet["networks"]["v4"][0]["ip_address"], 22)) == 0: 94 | break 95 | time.sleep(1) 96 | return droplet 97 | 98 | def new_droplet(**kwargs): 99 | """ Arguments: https://developers.digitalocean.com/documentation/v2/#create-a-new-droplet """ 100 | return post(get_token(), "/droplets", json=kwargs) 101 | 102 | def delete_droplet(id): 103 | return delete(get_token(), "/droplets/{}".format(id)) 104 | -------------------------------------------------------------------------------- /runin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blha303/DO-runin/4e725165e79f8bc0a2e1cb07a83f414686570e90/runin/__init__.py -------------------------------------------------------------------------------- /runin/__main__.py: -------------------------------------------------------------------------------- 1 | from .runin import main 2 | main() 3 | -------------------------------------------------------------------------------- /runin/runin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import os 5 | import subprocess 6 | import argparse 7 | import uuid 8 | import sys 9 | import runin.DO as DO 10 | 11 | DEFAULT_IMAGE = "ubuntu-16-04-x64" 12 | 13 | def match_keys(inp, p=False): 14 | """Takes a comma-separated string of key ids or fingerprints and returns a list of key ids""" 15 | _keys = [] 16 | ssh_keys = DO.get_ssh_keys() 17 | for k in inp.split(","): 18 | done = False 19 | if k.isdigit(): 20 | for _ in [s for s in ssh_keys if s["id"] == int(k)]: 21 | done = True 22 | _keys.append(_["fingerprint"]) 23 | else: 24 | for _ in [s for s in ssh_keys if s["fingerprint"] == k]: 25 | done = True 26 | _keys.append(_["fingerprint"]) 27 | if p and not done: 28 | print("Could not find a match for '{}', skipping".format(k), file=sys.stderr) 29 | return _keys 30 | 31 | def main(): 32 | parser = argparse.ArgumentParser(prog="runin", epilog="You must provide either -R/-S/-I, or -r") 33 | parser.add_argument("-R", "--list-regions", help="List available regions and exit", action="store_true") 34 | parser.add_argument("-S", "--list-ssh-keys", help="List available SSH keys and exit", action="store_true") 35 | parser.add_argument("-I", "--list-images", help="List available images and exit", action="store_true") 36 | parser.add_argument("-c", "--command", help="Run command. If none specified, opens a shell", default="") 37 | parser.add_argument("-r", "--region", help="Set region. Required") 38 | parser.add_argument("--name", help="Set droplet name. If not specified, a temporary name is generated", default=uuid.uuid4().hex) 39 | parser.add_argument("--size", help="Set droplet size. Defaults to 512mb", default="512mb") 40 | parser.add_argument("-s", "--ssh-keys", help="Set SSH keys. Comma separated list of numeric IDs or fingerprints. If not specified, a root password will be emailed", default="") 41 | parser.add_argument("-i", "--image", help="Set image. Defaults to " + DEFAULT_IMAGE, default=DEFAULT_IMAGE) 42 | parser.add_argument("--ipv6", help="Enable IPv6", action="store_true") 43 | parser.add_argument("--private-networking", help="Enable private networking", action="store_true") 44 | parser.add_argument("--shell", help="Run SSH using the less secure os.system, allowing usage of the created droplet in shell mode. Enabled automatically if -c is not provided", action="store_true") 45 | parser.add_argument("-k", "--keep", help="Keep server after disconnect", action="store_true") 46 | args = parser.parse_args() 47 | if args.list_regions: 48 | r = DO.get_regions(p=True) 49 | return 0 50 | if args.list_ssh_keys: 51 | s = DO.get_ssh_keys(p=True) 52 | return 0 53 | if args.list_images: 54 | i = DO.get_images(p=True) 55 | return 0 56 | regions = DO.get_regions() 57 | if args.region: 58 | if args.region not in regions: 59 | print("{} not a valid region. Use -R to list available regions".format(args.region), file=sys.stderr) 60 | return 1 61 | _keys = match_keys(args.ssh_keys) 62 | droplet = DO.new_droplet(region=args.region, 63 | name=args.name, 64 | size=args.size, 65 | ssh_keys=match_keys(args.ssh_keys, p=True), 66 | image=args.image, 67 | ipv6=args.ipv6, 68 | private_networking=args.private_networking) 69 | action = DO.get_action(droplet["links"]["actions"][0]["id"], p=True) 70 | if not action: 71 | print("Droplet creation errored. https://cloud.digitalocean.com/droplets", file=sys.stderr) 72 | return 2 73 | droplet["droplet"].update(action) 74 | ssh = ["ssh", "-oStrictHostKeyChecking=no", "root@" + droplet["droplet"]["networks"]["v4"][0]["ip_address"], args.command] 75 | if args.shell or not args.command: 76 | os.system(" ".join(["'{}'".format(a.replace("'", "\\'")) for a in ssh])) 77 | else: 78 | print(subprocess.check_output(ssh, shell=False)) 79 | if not args.keep: 80 | if not DO.delete_droplet(droplet["droplet"]["id"]): 81 | print("Droplet deletion errored. https://cloud.digitalocean.com/droplets", file=sys.stderr) 82 | return 2 83 | else: 84 | print("Server is accessible at {}".format(droplet["droplet"]["networks"]["v4"][0]["ip_address"])) 85 | return 0 86 | else: 87 | parser.print_help() 88 | return 1 89 | 90 | if __name__ == "__main__": 91 | sys.exit(main()) 92 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | desc = "A tool that starts a DigitalOcean droplet in a given region and runs a given command, displaying the output. Opens a shell if requested. Destroys the droplet upon command completion or shell closure." 4 | 5 | setup( 6 | name = "DO_runin", 7 | packages = ["runin"], 8 | install_requires = ['requests'], 9 | entry_points = { 10 | "console_scripts": ['runin = runin.runin:main'] 11 | }, 12 | version = "1.0.2", 13 | description = desc, 14 | long_description = desc, 15 | author = "Steven Smith", 16 | author_email = "stevensmith.ome@gmail.com", 17 | license = "MIT", 18 | url = "https://github.com/blha303/DO-runin", 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 2.7", 23 | "Programming Language :: Python :: 2", 24 | "Programming Language :: Python :: 3.4", 25 | "Programming Language :: Python :: 3", 26 | "Intended Audience :: End Users/Desktop", 27 | "Intended Audience :: System Administrators", 28 | ] 29 | ) 30 | --------------------------------------------------------------------------------