github.com/tmlbl/deis@v1.0.2/client/deis.py (about) 1 #!/usr/bin/env python 2 """ 3 The Deis command-line client issues API calls to a Deis controller. 4 5 Usage: deis <command> [<args>...] 6 7 Auth commands:: 8 9 register register a new user with a controller 10 login login to a controller 11 logout logout from the current controller 12 13 Subcommands, use ``deis help [subcommand]`` to learn more:: 14 15 apps manage applications used to provide services 16 ps manage processes inside an app container 17 config manage environment variables that define app config 18 domains manage and assign domain names to your applications 19 builds manage builds created using `git push` 20 limits manage resource limits for your application 21 tags manage tags for application containers 22 releases manage releases of an application 23 24 keys manage ssh keys used for `git push` deployments 25 perms manage permissions for applications 26 27 Developer shortcut commands:: 28 29 create create a new application 30 scale scale processes by type (web=2, worker=1) 31 info view information about the current app 32 open open a URL to the app in a browser 33 logs view aggregated log info for the app 34 run run a command in an ephemeral app container 35 destroy destroy an application 36 37 Use ``git push deis master`` to deploy to an application. 38 39 """ 40 41 from __future__ import print_function 42 from collections import namedtuple 43 from collections import OrderedDict 44 from datetime import datetime 45 from getpass import getpass 46 from itertools import cycle 47 from threading import Event 48 from threading import Thread 49 import base64 50 import glob 51 import json 52 import locale 53 import logging 54 import os.path 55 import re 56 import subprocess 57 import sys 58 import time 59 import urlparse 60 import webbrowser 61 62 from dateutil import parser 63 from dateutil import relativedelta 64 from dateutil import tz 65 from docopt import docopt 66 from docopt import DocoptExit 67 import requests 68 from termcolor import colored 69 70 __version__ = '1.0.2' 71 72 73 locale.setlocale(locale.LC_ALL, '') 74 75 76 class Session(requests.Session): 77 """ 78 Session for making API requests and interacting with the filesystem 79 """ 80 81 def __init__(self): 82 super(Session, self).__init__() 83 self.trust_env = False 84 config_dir = os.path.expanduser('~/.deis') 85 self.proxies = { 86 "http": os.getenv("http_proxy"), 87 "https": os.getenv("https_proxy") 88 } 89 # Create the $HOME/.deis dir if it doesn't exist 90 if not os.path.isdir(config_dir): 91 os.mkdir(config_dir, 0700) 92 93 @property 94 def app(self): 95 """Retrieve the application's name.""" 96 try: 97 return self._get_name_from_git_remote(self.git_root()).lower() 98 except EnvironmentError: 99 return os.path.basename(os.getcwd()).lower() 100 101 def is_git_app(self): 102 """Determines if this app is a git repository. This is important in special cases 103 where we need to know whether or not we should use Deis' automatic app name 104 generator, for example. 105 """ 106 try: 107 self.git_root() 108 return True 109 except EnvironmentError: 110 return False 111 112 def git_root(self): 113 """ 114 Returns the absolute path from the git repository root. 115 116 If no git repository exists, raises an EnvironmentError. 117 """ 118 try: 119 git_root = subprocess.check_output( 120 ['git', 'rev-parse', '--show-toplevel'], 121 stderr=subprocess.PIPE).strip('\n') 122 except subprocess.CalledProcessError: 123 raise EnvironmentError('Current directory is not a git repository') 124 return git_root 125 126 def _get_name_from_git_remote(self, git_root): 127 """ 128 Retrieves the application name from a git repository root. 129 130 The application is determined by parsing `git remote -v` output. 131 If no application is found, raises an EnvironmentError. 132 """ 133 remotes = subprocess.check_output(['git', 'remote', '-v'], 134 cwd=git_root) 135 m = re.search(r'^deis\W+(?P<url>\S+)\W+\(', remotes, re.MULTILINE) 136 if not m: 137 raise EnvironmentError( 138 'Could not find deis remote in `git remote -v`') 139 url = m.groupdict()['url'] 140 m = re.match('\S+/(?P<app>[a-z0-9-]+)(.git)?$', url) 141 if not m: 142 raise EnvironmentError("Could not parse: {url}".format(**locals())) 143 return m.groupdict()['app'] 144 145 def request(self, *args, **kwargs): 146 """ 147 Issue an HTTP request 148 """ 149 url = args[1] 150 if 'headers' in kwargs: 151 kwargs['headers']['Referer'] = url 152 else: 153 kwargs['headers'] = {'Referer': url} 154 response = super(Session, self).request(*args, **kwargs) 155 return response 156 157 158 class Settings(dict): 159 """ 160 Settings backed by a file in the user's home directory 161 162 On init, settings are loaded from ~/.deis/client.json 163 """ 164 165 def __init__(self): 166 path = os.path.expanduser('~/.deis') 167 # Create the $HOME/.deis dir if it doesn't exist 168 if not os.path.isdir(path): 169 os.mkdir(path, 0700) 170 self._path = os.path.join(path, 'client.json') 171 if not os.path.exists(self._path): 172 settings = {} 173 # try once to convert the old settings file if it exists 174 # FIXME: this code can be removed in November 2014 or thereabouts, that's long enough. 175 old_path = os.path.join(path, 'client.yaml') 176 if os.path.exists(old_path): 177 try: 178 with open(old_path, 'r') as f: 179 txt = f.read().replace('{', '{"', 1).replace(':', '":', 1).replace("'", '"') 180 settings = json.loads(txt) 181 os.remove(old_path) 182 except: 183 pass # ignore errors, at least we tried to convert it 184 with open(self._path, 'w') as f: 185 json.dump(settings, f) 186 # load initial settings 187 self.load() 188 189 def load(self): 190 """ 191 Deserialize and load settings from the filesystem 192 """ 193 with open(self._path) as f: 194 data = f.read() 195 settings = json.loads(data) 196 self.update(settings) 197 return settings 198 199 def save(self): 200 """ 201 Serialize and save settings to the filesystem 202 """ 203 data = json.dumps(dict(self)) 204 try: 205 with open(self._path, 'w') as f: 206 f.write(data) 207 except IOError: 208 logging.getLogger(__name__).error("Could not write to settings file at \ 209 '~/.deis/client.json' Do you have the right file permissions?") 210 sys.exit(1) 211 return data 212 213 214 _counter = 0 215 216 217 def _newname(template="Thread-{}"): 218 """Generate a new thread name.""" 219 global _counter 220 _counter += 1 221 return template.format(_counter) 222 223 224 FRAMES = { 225 'arrow': ['^', '>', 'v', '<'], 226 'dots': ['...', 'o..', '.o.', '..o'], 227 'ligatures': ['bq', 'dp', 'qb', 'pd'], 228 'lines': [' ', '-', '=', '#', '=', '-'], 229 'slash': ['-', '\\', '|', '/'], 230 } 231 232 233 class TextProgress(Thread): 234 """Show progress for a long-running operation on the command-line.""" 235 236 def __init__(self, group=None, target=None, name=None, args=(), kwargs={}): 237 name = name or _newname("TextProgress-Thread-{}") 238 style = kwargs.get('style', 'dots') 239 super(TextProgress, self).__init__( 240 group, target, name, args, kwargs) 241 self.daemon = True 242 self.cancelled = Event() 243 self.frames = cycle(FRAMES[style]) 244 245 def run(self): 246 """Write ASCII progress animation frames to stdout.""" 247 if not os.environ.get('DEIS_HIDE_PROGRESS'): 248 time.sleep(0.5) 249 self._write_frame(self.frames.next(), erase=False) 250 while not self.cancelled.is_set(): 251 time.sleep(0.4) 252 self._write_frame(self.frames.next()) 253 # clear the animation 254 sys.stdout.write('\b' * (len(self.frames.next()) + 2)) 255 sys.stdout.flush() 256 257 def cancel(self): 258 """Set the animation thread as cancelled.""" 259 self.cancelled.set() 260 261 def _write_frame(self, frame, erase=True): 262 if erase: 263 backspaces = '\b' * (len(frame) + 2) 264 else: 265 backspaces = '' 266 sys.stdout.write("{} {} ".format(backspaces, frame)) 267 # flush stdout or we won't see the frame 268 sys.stdout.flush() 269 270 271 def dictify(args): 272 """Converts a list of key=val strings into a python dict. 273 274 >>> dictify(['MONGODB_URL=http://mongolabs.com/test', 'scale=5']) 275 {'MONGODB_URL': 'http://mongolabs.com/test', 'scale': 5} 276 """ 277 data = {} 278 for arg in args: 279 try: 280 var, val = arg.split('=', 1) 281 except ValueError: 282 raise DocoptExit() 283 # Try to coerce the value to an int since that's a common use case 284 try: 285 data[var] = int(val) 286 except ValueError: 287 data[var] = val 288 return data 289 290 291 def encode(obj): 292 """Return UTF-8 encoding for string objects.""" 293 if isinstance(obj, basestring): 294 return obj.encode('utf-8') 295 else: 296 return obj 297 298 299 def readable_datetime(datetime_str): 300 """ 301 Return a human-readable datetime string from an ECMA-262 (JavaScript) 302 datetime string. 303 """ 304 timezone = tz.tzlocal() 305 dt = parser.parse(datetime_str).astimezone(timezone) 306 now = datetime.now(timezone) 307 delta = relativedelta.relativedelta(now, dt) 308 # if it happened today, say "2 hours and 1 minute ago" 309 if delta.days <= 1 and dt.day == now.day: 310 if delta.hours == 0: 311 hour_str = '' 312 elif delta.hours == 1: 313 hour_str = '1 hour ' 314 else: 315 hour_str = "{} hours ".format(delta.hours) 316 if delta.minutes == 0: 317 min_str = '' 318 elif delta.minutes == 1: 319 min_str = '1 minute ' 320 else: 321 min_str = "{} minutes ".format(delta.minutes) 322 if not any((hour_str, min_str)): 323 return 'Just now' 324 else: 325 return "{}{}ago".format(hour_str, min_str) 326 # if it happened yesterday, say "yesterday at 3:23 pm" 327 yesterday = now + relativedelta.relativedelta(days=-1) 328 if delta.days <= 2 and dt.day == yesterday.day: 329 return dt.strftime("Yesterday at %X") 330 # otherwise return locale-specific date/time format 331 else: 332 return dt.strftime('%c %Z') 333 334 335 def trim(docstring): 336 """ 337 Function to trim whitespace from docstring 338 339 c/o PEP 257 Docstring Conventions 340 <http://www.python.org/dev/peps/pep-0257/> 341 """ 342 if not docstring: 343 return '' 344 # Convert tabs to spaces (following the normal Python rules) 345 # and split into a list of lines: 346 lines = docstring.expandtabs().splitlines() 347 # Determine minimum indentation (first line doesn't count): 348 indent = sys.maxint 349 for line in lines[1:]: 350 stripped = line.lstrip() 351 if stripped: 352 indent = min(indent, len(line) - len(stripped)) 353 # Remove indentation (first line is special): 354 trimmed = [lines[0].strip()] 355 if indent < sys.maxint: 356 for line in lines[1:]: 357 trimmed.append(line[indent:].rstrip()) 358 # Strip off trailing and leading blank lines: 359 while trimmed and not trimmed[-1]: 360 trimmed.pop() 361 while trimmed and not trimmed[0]: 362 trimmed.pop(0) 363 # Return a single string: 364 return '\n'.join(trimmed) 365 366 367 class ResponseError(Exception): 368 pass 369 370 371 class DeisClient(object): 372 """ 373 A client which interacts with a Deis controller. 374 """ 375 376 def __init__(self): 377 self._session = Session() 378 self._settings = Settings() 379 self._logger = logging.getLogger(__name__) 380 381 def _dispatch(self, method, path, body=None, **kwargs): 382 """ 383 Dispatch an API request to the active Deis controller 384 """ 385 func = getattr(self._session, method.lower()) 386 controller = self._settings.get('controller') 387 token = self._settings.get('token') 388 if not token: 389 raise EnvironmentError( 390 'Could not find token. Use `deis login` or `deis register` to get started.') 391 url = urlparse.urljoin(controller, path, **kwargs) 392 headers = { 393 'content-type': 'application/json', 394 'X-Deis-Version': __version__.rsplit('.', 1)[0], 395 'Authorization': 'token {}'.format(token) 396 } 397 response = func(url, data=body, headers=headers) 398 return response 399 400 def apps(self, args): 401 """ 402 Valid commands for apps: 403 404 apps:create create a new application 405 apps:list list accessible applications 406 apps:info view info about an application 407 apps:open open the application in a browser 408 apps:logs view aggregated application logs 409 apps:run run a command in an ephemeral app container 410 apps:destroy destroy an application 411 412 Use `deis help [command]` to learn more. 413 """ 414 sys.argv[1] = 'apps:list' 415 args = docopt(self.apps_list.__doc__) 416 return self.apps_list(args) 417 418 def apps_create(self, args): 419 """ 420 Creates a new application. 421 422 - if no <id> is provided, one will be generated automatically. 423 424 Usage: deis apps:create [<id>] [options] 425 426 Arguments: 427 <id> 428 a uniquely identifiable name for the application. No other app can already 429 exist with this name. 430 431 Options: 432 --no-remote 433 do not create a `deis` git remote. 434 """ 435 body = {} 436 app_name = None 437 if not self._session.is_git_app(): 438 app_name = self._session.app 439 # prevent app name from being reset to None 440 if args.get('<id>'): 441 app_name = args.get('<id>') 442 if app_name: 443 body.update({'id': app_name}) 444 sys.stdout.write('Creating application... ') 445 sys.stdout.flush() 446 try: 447 progress = TextProgress() 448 progress.start() 449 response = self._dispatch('post', '/v1/apps', 450 json.dumps(body)) 451 finally: 452 progress.cancel() 453 progress.join() 454 if response.status_code == requests.codes.created: 455 data = response.json() 456 app_id = data['id'] 457 self._logger.info("done, created {}".format(app_id)) 458 # set a git remote if necessary 459 try: 460 self._session.git_root() 461 except EnvironmentError: 462 return 463 hostname = urlparse.urlparse(self._settings['controller']).netloc.split(':')[0] 464 git_remote = "ssh://git@{hostname}:2222/{app_id}.git".format(**locals()) 465 if args.get('--no-remote'): 466 self._logger.info('remote available at {}'.format(git_remote)) 467 else: 468 try: 469 subprocess.check_call( 470 ['git', 'remote', 'add', '-f', 'deis', git_remote], 471 stdout=subprocess.PIPE) 472 self._logger.info('Git remote deis added') 473 except subprocess.CalledProcessError: 474 self._logger.error('Could not create Deis remote') 475 sys.exit(1) 476 else: 477 raise ResponseError(response) 478 479 def apps_destroy(self, args): 480 """ 481 Destroys an application. 482 483 Usage: deis apps:destroy [options] 484 485 Options: 486 -a --app=<app> 487 the uniquely identifiable name for the application. 488 489 --confirm=<app> 490 skips the prompt for the application name. <app> is the uniquely identifiable 491 name for the application. 492 """ 493 app = args.get('--app') 494 if not app: 495 app = self._session.app 496 confirm = args.get('--confirm') 497 if confirm == app: 498 pass 499 else: 500 self._logger.warning(""" 501 ! WARNING: Potentially Destructive Action 502 ! This command will destroy the application: {app} 503 ! To proceed, type "{app}" or re-run this command with --confirm={app} 504 """.format(**locals())) 505 confirm = raw_input('> ').strip('\n') 506 if confirm != app: 507 self._logger.info('Destroy aborted') 508 return 509 self._logger.info("Destroying {}... ".format(app)) 510 try: 511 progress = TextProgress() 512 progress.start() 513 before = time.time() 514 response = self._dispatch('delete', "/v1/apps/{}".format(app)) 515 finally: 516 progress.cancel() 517 progress.join() 518 if response.status_code in (requests.codes.no_content, 519 requests.codes.not_found): 520 self._logger.info('done in {}s'.format(int(time.time() - before))) 521 try: 522 # If the requested app is a heroku app, delete the git remote 523 if self._session.is_git_app(): 524 subprocess.check_call( 525 ['git', 'remote', 'rm', 'deis'], 526 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 527 self._logger.info('Git remote deis removed') 528 except (EnvironmentError, subprocess.CalledProcessError): 529 pass # ignore error 530 else: 531 raise ResponseError(response) 532 533 def apps_list(self, args): 534 """ 535 Lists applications visible to the current user. 536 537 Usage: deis apps:list 538 """ 539 response = self._dispatch('get', '/v1/apps') 540 if response.status_code == requests.codes.ok: 541 data = response.json() 542 self._logger.info('=== Apps') 543 for item in data['results']: 544 self._logger.info('{id}'.format(**item)) 545 else: 546 raise ResponseError(response) 547 548 def apps_info(self, args): 549 """ 550 Prints info about the current application. 551 552 Usage: deis apps:info [options] 553 554 Options: 555 -a --app=<app> 556 the uniquely identifiable name for the application. 557 """ 558 app = args.get('--app') 559 if not app: 560 app = self._session.app 561 response = self._dispatch('get', "/v1/apps/{}".format(app)) 562 if response.status_code == requests.codes.ok: 563 self._logger.info("=== {} Application".format(app)) 564 self._logger.info(json.dumps(response.json(), indent=2) + '\n') 565 self.ps_list(args) 566 self.domains_list(args) 567 self._logger.info('') 568 else: 569 raise ResponseError(response) 570 571 def apps_open(self, args): 572 """ 573 Opens a URL to the application in the default browser. 574 575 Usage: deis apps:open [options] 576 577 Options: 578 -a --app=<app> 579 the uniquely identifiable name for the application. 580 """ 581 app = args.get('--app') 582 if not app: 583 app = self._session.app 584 # TODO: replace with a single API call to apps endpoint 585 response = self._dispatch('get', "/v1/apps/{}".format(app)) 586 if response.status_code == requests.codes.ok: 587 url = response.json()['url'] 588 # use the OS's default handler to open this URL 589 webbrowser.open('http://{}/'.format(url)) 590 return url 591 else: 592 raise ResponseError(response) 593 594 def apps_logs(self, args): 595 """ 596 Retrieves the most recent log events. 597 598 Usage: deis apps:logs [options] 599 600 Options: 601 -a --app=<app> 602 the uniquely identifiable name for the application. 603 """ 604 app = args.get('--app') 605 if not app: 606 app = self._session.app 607 response = self._dispatch('get', 608 "/v1/apps/{}/logs".format(app)) 609 if response.status_code == requests.codes.ok: 610 # strip the last newline character 611 for line in response.json().split('\n')[:-1]: 612 # get the tag from the log 613 try: 614 log_tag = line.split(': ')[0].split(' ')[1] 615 # colorize the log based on the tag 616 color = sum([ord(ch) for ch in log_tag]) % 6 617 def f(x): 618 return { 619 0: 'green', 620 1: 'cyan', 621 2: 'red', 622 3: 'yellow', 623 4: 'blue', 624 5: 'magenta', 625 }.get(x, 'magenta') 626 self._logger.info(colored(line, f(color))) 627 except IndexError: 628 self._logger.info(line) 629 else: 630 raise ResponseError(response) 631 632 def apps_run(self, args): 633 """ 634 Runs a command inside an ephemeral app container. Default environment is 635 /bin/bash. 636 637 Usage: deis apps:run [options] [--] <command>... 638 639 Arguments: 640 <command> 641 the shell command to run inside the container. 642 643 Options: 644 -a --app=<app> 645 the uniquely identifiable name for the application. 646 """ 647 command = ' '.join(args.get('<command>')) 648 self._logger.info('Running `{}`...'.format(command)) 649 650 app = args.get('--app') 651 if not app: 652 app = self._session.app 653 body = {'command': command} 654 response = self._dispatch('post', 655 "/v1/apps/{}/run".format(app), 656 json.dumps(body)) 657 if response.status_code == requests.codes.ok: 658 rc, output = json.loads(response.content) 659 sys.stdout.write(output) 660 sys.stdout.flush() 661 sys.exit(rc) 662 else: 663 raise ResponseError(response) 664 665 def auth(self, args): 666 """ 667 Valid commands for auth: 668 669 auth:register register a new user 670 auth:login authenticate against a controller 671 auth:logout clear the current user session 672 auth:passwd change the password for the current user 673 auth:whoami display the current user 674 auth:cancel remove the current user account 675 676 Use `deis help [command]` to learn more. 677 """ 678 return 679 680 def auth_register(self, args): 681 """ 682 Registers a new user with a Deis controller. 683 684 Usage: deis auth:register <controller> [options] 685 686 Arguments: 687 <controller> 688 fully-qualified controller URI, e.g. `http://deis.local.deisapp.com/` 689 690 Options: 691 --username=<username> 692 provide a username for the new account. 693 --password=<password> 694 provide a password for the new account. 695 --email=<email> 696 provide an email address. 697 """ 698 controller = args['<controller>'] 699 if not urlparse.urlparse(controller).scheme: 700 controller = "http://{}".format(controller) 701 username = args.get('--username') 702 if not username: 703 username = raw_input('username: ') 704 password = args.get('--password') 705 if not password: 706 password = getpass('password: ') 707 confirm = getpass('password (confirm): ') 708 if password != confirm: 709 self._logger.error('Password mismatch, aborting registration.') 710 sys.exit(1) 711 email = args.get('--email') 712 if not email: 713 email = raw_input('email: ') 714 url = urlparse.urljoin(controller, '/v1/auth/register') 715 payload = {'username': username, 'password': password, 'email': email} 716 response = self._session.post(url, data=payload, allow_redirects=False) 717 if response.status_code == requests.codes.created: 718 self._settings['controller'] = controller 719 self._settings.save() 720 self._logger.info("Registered {}".format(username)) 721 login_args = {'--username': username, '--password': password, 722 '<controller>': controller} 723 if self.auth_login(login_args) is False: 724 self._logger.info('Login failed') 725 else: 726 self._logger.info('Registration failed: ' + response.content) 727 sys.exit(1) 728 return True 729 730 def auth_cancel(self, args): 731 """ 732 Cancels and removes the current account. 733 734 Usage: deis auth:cancel 735 """ 736 controller = self._settings.get('controller') 737 if not controller: 738 self._logger.error('Not logged in to a Deis controller') 739 sys.exit(1) 740 self._logger.info('Please log in again in order to cancel this account') 741 username = self.auth_login({'<controller>': controller}) 742 if username: 743 confirm = raw_input("Cancel account \"{}\" at {}? (y/n) ".format(username, controller)) 744 if confirm == 'y': 745 self._dispatch('delete', '/v1/auth/cancel') 746 self._settings['controller'] = None 747 self._settings['token'] = None 748 self._settings.save() 749 self._logger.info('Account cancelled') 750 else: 751 self._logger.info('Account not changed') 752 753 def auth_login(self, args): 754 """ 755 Logs in by authenticating against a controller. 756 757 Usage: deis auth:login <controller> [options] 758 759 Arguments: 760 <controller> 761 a fully-qualified controller URI, e.g. `http://deis.local.deisapp.com/`. 762 763 Options: 764 --username=<username> 765 provide a username for the account. 766 --password=<password> 767 provide a password for the account. 768 """ 769 controller = args['<controller>'] 770 if not urlparse.urlparse(controller).scheme: 771 controller = "http://{}".format(controller) 772 username = args.get('--username') 773 headers = {} 774 if not username: 775 username = raw_input('username: ') 776 password = args.get('--password') 777 if not password: 778 password = getpass('password: ') 779 url = urlparse.urljoin(controller, '/v1/auth/login/') 780 payload = {'username': username, 'password': password} 781 # post credentials to the login URL 782 response = self._session.post(url, data=payload, allow_redirects=False) 783 if response.status_code == requests.codes.ok: 784 # retrieve and save the API token for future requests 785 self._settings['controller'] = controller 786 self._settings['username'] = username 787 self._settings['token'] = response.json()['token'] 788 self._settings.save() 789 self._logger.info("Logged in as {}".format(username)) 790 return username 791 else: 792 raise ResponseError(response) 793 794 def auth_logout(self, args): 795 """ 796 Logs out from a controller and clears the user session. 797 798 Usage: deis auth:logout 799 """ 800 self._settings['controller'] = None 801 self._settings['username'] = None 802 self._settings['token'] = None 803 self._settings.save() 804 self._logger.info('Logged out') 805 806 def auth_passwd(self, args): 807 """ 808 Changes the password for the current user. 809 810 Usage: deis auth:passwd [options] 811 812 Options: 813 --password=<password> 814 provide the current password for the account. 815 --new-password=<new-password> 816 provide a new password for the account. 817 """ 818 if not self._settings.get('token'): 819 raise EnvironmentError( 820 'Could not find token. Use `deis login` or `deis register` to get started.') 821 password = args.get('--password') 822 if not password: 823 password = getpass('current password: ') 824 new_password = args.get('--new-password') 825 if not new_password: 826 new_password = getpass('new password: ') 827 confirm = getpass('new password (confirm): ') 828 if new_password != confirm: 829 self._logger.error('Password mismatch, not changing.') 830 sys.exit(1) 831 payload = {'password': password, 'new_password': new_password} 832 response = self._dispatch('post', "/v1/auth/passwd", json.dumps(payload)) 833 if response.status_code == requests.codes.ok: 834 self._logger.info('Password change succeeded.') 835 else: 836 self._logger.info("Password change failed: {}".format(response.text)) 837 sys.exit(1) 838 return True 839 840 def auth_whoami(self, args): 841 """ 842 Displays the currently logged in user. 843 844 Usage: deis auth:whoami 845 """ 846 user = self._settings.get('username') 847 if user: 848 self._logger.info(user) 849 else: 850 self._logger.info( 851 'Not logged in. Use `deis login` or `deis register` to get started.') 852 853 def builds(self, args): 854 """ 855 Valid commands for builds: 856 857 builds:list list build history for an application 858 builds:create imports an image and deploys as a new release 859 860 Use `deis help [command]` to learn more. 861 """ 862 sys.argv[1] = 'builds:list' 863 args = docopt(self.builds_list.__doc__) 864 return self.builds_list(args) 865 866 def builds_create(self, args): 867 """ 868 Creates a new build of an application. Imports an <image> and deploys it to Deis 869 as a new release. 870 871 Usage: deis builds:create <image> [options] 872 873 Arguments: 874 <image> 875 A fully-qualified docker image, either from Docker Hub (e.g. deis/example-go) 876 or from an in-house registry (e.g. myregistry.example.com:5000/example-go). 877 878 Options: 879 -a --app=<app> 880 The uniquely identifiable name for the application. 881 """ 882 app = args.get('--app') 883 if not app: 884 app = self._session.app 885 body = {'image': args['<image>']} 886 sys.stdout.write('Creating build... ') 887 sys.stdout.flush() 888 try: 889 progress = TextProgress() 890 progress.start() 891 response = self._dispatch('post', "/v1/apps/{}/builds".format(app), json.dumps(body)) 892 finally: 893 progress.cancel() 894 progress.join() 895 if response.status_code == requests.codes.created: 896 version = response.headers['x-deis-release'] 897 self._logger.info("done, v{}".format(version)) 898 else: 899 raise ResponseError(response) 900 901 def builds_list(self, args): 902 """ 903 Lists build history for an application. 904 905 Usage: deis builds:list [options] 906 907 Options: 908 -a --app=<app> 909 the uniquely identifiable name for the application. 910 """ 911 app = args.get('--app') 912 if not app: 913 app = self._session.app 914 response = self._dispatch('get', "/v1/apps/{}/builds".format(app)) 915 if response.status_code == requests.codes.ok: 916 self._logger.info("=== {} Builds".format(app)) 917 data = response.json() 918 for item in data['results']: 919 self._logger.info("{0[uuid]:<23} {0[created]}".format(item)) 920 else: 921 raise ResponseError(response) 922 923 def config(self, args): 924 """ 925 Valid commands for config: 926 927 config:list list environment variables for an app 928 config:set set environment variables for an app 929 config:unset unset environment variables for an app 930 config:pull extract environment variables to .env 931 932 Use `deis help [command]` to learn more. 933 """ 934 sys.argv[1] = 'config:list' 935 args = docopt(self.config_list.__doc__) 936 return self.config_list(args) 937 938 def config_list(self, args): 939 """ 940 Lists environment variables for an application. 941 942 Usage: deis config:list [options] 943 944 Options: 945 --oneline 946 print output on one line. 947 948 -a --app=<app> 949 the uniquely identifiable name of the application. 950 """ 951 app = args.get('--app') 952 if not app: 953 app = self._session.app 954 955 oneline = args.get('--oneline') 956 response = self._dispatch('get', "/v1/apps/{}/config".format(app)) 957 if response.status_code == requests.codes.ok: 958 config = response.json() 959 values = config['values'] 960 self._logger.info("=== {} Config".format(app)) 961 items = values.items() 962 if len(items) == 0: 963 self._logger.info('No configuration') 964 return 965 keys = sorted(values) 966 967 if not oneline: 968 width = max(map(len, keys)) + 5 969 for k in keys: 970 k, v = encode(k), encode(values[k]) 971 self._logger.info(("{k:<" + str(width) + "} {v}").format(**locals())) 972 else: 973 output = [] 974 for k in keys: 975 k, v = encode(k), encode(values[k]) 976 output.append("{k}={v}".format(**locals())) 977 self._logger.info(' '.join(output)) 978 else: 979 raise ResponseError(response) 980 981 def config_set(self, args): 982 """ 983 Sets environment variables for an application. 984 985 Usage: deis config:set <var>=<value> [<var>=<value>...] [options] 986 987 Arguments: 988 <var> 989 the uniquely identifiable name for the environment variable. 990 <value> 991 the value of said environment variable. 992 993 Options: 994 -a --app=<app> 995 the uniquely identifiable name for the application. 996 """ 997 app = args.get('--app') 998 if not app: 999 app = self._session.app 1000 body = {'values': json.dumps(dictify(args['<var>=<value>']))} 1001 sys.stdout.write('Creating config... ') 1002 sys.stdout.flush() 1003 try: 1004 progress = TextProgress() 1005 progress.start() 1006 response = self._dispatch('post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1007 finally: 1008 progress.cancel() 1009 progress.join() 1010 if response.status_code == requests.codes.created: 1011 version = response.headers['x-deis-release'] 1012 self._logger.info("done, v{}\n".format(version)) 1013 config = response.json() 1014 values = config['values'] 1015 self._logger.info("=== {}".format(app)) 1016 items = values.items() 1017 if len(items) == 0: 1018 self._logger.info('No configuration') 1019 return 1020 for k, v in values.items(): 1021 self._logger.info("{}: {}".format(encode(k), encode(v))) 1022 else: 1023 raise ResponseError(response) 1024 1025 def config_unset(self, args): 1026 """ 1027 Unsets an environment variable for an application. 1028 1029 Usage: deis config:unset <key>... [options] 1030 1031 Arguments: 1032 <key> 1033 the variable to remove from the application's environment. 1034 1035 Options: 1036 -a --app=<app> 1037 the uniquely identifiable name for the application. 1038 """ 1039 app = args.get('--app') 1040 if not app: 1041 app = self._session.app 1042 values = {} 1043 for k in args.get('<key>'): 1044 values[k] = None 1045 body = {'values': json.dumps(values)} 1046 sys.stdout.write('Creating config... ') 1047 sys.stdout.flush() 1048 try: 1049 progress = TextProgress() 1050 progress.start() 1051 response = self._dispatch( 1052 'post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1053 finally: 1054 progress.cancel() 1055 progress.join() 1056 if response.status_code == requests.codes.created: 1057 version = response.headers['x-deis-release'] 1058 self._logger.info("done, v{}\n".format(version)) 1059 config = response.json() 1060 values = config['values'] 1061 self._logger.info("=== {}".format(app)) 1062 items = values.items() 1063 if len(items) == 0: 1064 self._logger.info('No configuration') 1065 return 1066 for k, v in values.items(): 1067 self._logger.info("{k}: {v}".format(**locals())) 1068 else: 1069 raise ResponseError(response) 1070 1071 def config_pull(self, args): 1072 """ 1073 Extract all environment variables from an application for local use. 1074 1075 Your environment will be stored locally in a file named .env. This file can be 1076 read by foreman to load the local environment for your app. 1077 1078 Usage: deis config:pull [options] 1079 1080 Options: 1081 -a --app=<app> 1082 The application that you wish to pull from 1083 -i --interactive 1084 Prompts for each value to be overwritten 1085 -o --overwrite 1086 Allows you to have the pull overwrite keys in .env 1087 """ 1088 app = args.get('--app') 1089 overwrite = args.get('--overwrite') 1090 interactive = args.get('--interactive') 1091 env_dict = {} 1092 if not app: 1093 app = self._session.app 1094 try: 1095 # load env_dict from existing .env, if it exists 1096 with open('.env') as f: 1097 for line in f.readlines(): 1098 k, v = line.split('=', 1)[0], line.split('=', 1)[1].strip('\n') 1099 env_dict[k] = v 1100 except IOError: 1101 pass 1102 response = self._dispatch('get', "/v1/apps/{}/config".format(app)) 1103 if response.status_code == requests.codes.ok: 1104 config = response.json()['values'] 1105 for k, v in config.items(): 1106 if interactive and raw_input("overwrite {} with {}? (y/N) ".format(k, v)) == 'y': 1107 env_dict[k] = v 1108 if k in env_dict and not overwrite: 1109 continue 1110 env_dict[k] = v 1111 # write env_dict to .env 1112 try: 1113 with open('.env', 'w') as f: 1114 for i in env_dict.keys(): 1115 f.write("{}={}\n".format(i, env_dict[i])) 1116 except IOError: 1117 self._logger.error('could not write to local env') 1118 sys.exit(1) 1119 else: 1120 raise ResponseError(response) 1121 1122 def domains(self, args): 1123 """ 1124 Valid commands for domains: 1125 1126 domains:add bind a domain to an application 1127 domains:list list domains bound to an application 1128 domains:remove unbind a domain from an application 1129 1130 Use `deis help [command]` to learn more. 1131 """ 1132 sys.argv[1] = 'domains:list' 1133 args = docopt(self.domains_list.__doc__) 1134 return self.domains_list(args) 1135 1136 def domains_add(self, args): 1137 """ 1138 Binds a domain to an application. 1139 1140 Usage: deis domains:add <domain> [options] 1141 1142 Arguments: 1143 <domain> 1144 the domain name to be bound to the application, such as `domain.deisapp.com`. 1145 1146 Options: 1147 -a --app=<app> 1148 the uniquely identifiable name for the application. 1149 """ 1150 app = args.get('--app') 1151 if not app: 1152 app = self._session.app 1153 domain = args.get('<domain>') 1154 body = {'domain': domain} 1155 sys.stdout.write("Adding {domain} to {app}... ".format(**locals())) 1156 sys.stdout.flush() 1157 try: 1158 progress = TextProgress() 1159 progress.start() 1160 response = self._dispatch( 1161 'post', "/v1/apps/{app}/domains".format(app=app), json.dumps(body)) 1162 finally: 1163 progress.cancel() 1164 progress.join() 1165 if response.status_code == requests.codes.created: 1166 self._logger.info("done") 1167 else: 1168 raise ResponseError(response) 1169 1170 def domains_remove(self, args): 1171 """ 1172 Unbinds a domain for an application. 1173 1174 Usage: deis domains:remove <domain> [options] 1175 1176 Arguments: 1177 <domain> 1178 the domain name to be removed from the application. 1179 1180 Options: 1181 -a --app=<app> 1182 the uniquely identifiable name for the application. 1183 """ 1184 app = args.get('--app') 1185 if not app: 1186 app = self._session.app 1187 domain = args.get('<domain>') 1188 sys.stdout.write("Removing {domain} from {app}... ".format(**locals())) 1189 sys.stdout.flush() 1190 try: 1191 progress = TextProgress() 1192 progress.start() 1193 response = self._dispatch( 1194 'delete', "/v1/apps/{app}/domains/{domain}".format(**locals())) 1195 finally: 1196 progress.cancel() 1197 progress.join() 1198 if response.status_code == requests.codes.no_content: 1199 self._logger.info("done") 1200 else: 1201 raise ResponseError(response) 1202 1203 def domains_list(self, args): 1204 """ 1205 Lists domains bound to an application. 1206 1207 Usage: deis domains:list [options] 1208 1209 Options: 1210 -a --app=<app> 1211 the uniquely identifiable name for the application. 1212 """ 1213 app = args.get('--app') 1214 if not app: 1215 app = self._session.app 1216 response = self._dispatch( 1217 'get', "/v1/apps/{app}/domains".format(app=app)) 1218 if response.status_code == requests.codes.ok: 1219 domains = response.json()['results'] 1220 self._logger.info("=== {} Domains".format(app)) 1221 if len(domains) == 0: 1222 self._logger.info('No domains') 1223 return 1224 for domain in domains: 1225 self._logger.info(domain['domain']) 1226 else: 1227 raise ResponseError(response) 1228 1229 def limits(self, args): 1230 """ 1231 Valid commands for limits: 1232 1233 limits:list list resource limits for an app 1234 limits:set set resource limits for an app 1235 limits:unset unset resource limits for an app 1236 1237 Use `deis help [command]` to learn more. 1238 """ 1239 sys.argv[1] = 'limits:list' 1240 args = docopt(self.limits_list.__doc__) 1241 return self.limits_list(args) 1242 1243 def limits_list(self, args): 1244 """ 1245 Lists resource limits for an application. 1246 1247 Usage: deis limits:list [options] 1248 1249 Options: 1250 -a --app=<app> 1251 the uniquely identifiable name of the application. 1252 """ 1253 app = args.get('--app') 1254 if not app: 1255 app = self._session.app 1256 response = self._dispatch('get', "/v1/apps/{}/config".format(app)) 1257 if response.status_code == requests.codes.ok: 1258 self._print_limits(app, response.json()) 1259 else: 1260 raise ResponseError(response) 1261 1262 def limits_set(self, args): 1263 """ 1264 Sets resource limits for an application. 1265 1266 A resource limit is a finite resource within a container which we can apply 1267 restrictions to either through the scheduler or through the Docker API. This limit 1268 is applied to each individual container, so setting a memory limit of 1G for an 1269 application means that each container gets 1G of memory. 1270 1271 Usage: deis limits:set [options] <type>=<limit>... 1272 1273 Arguments: 1274 <type> 1275 the process type as defined in your Procfile, such as 'web' or 'worker'. 1276 Note that Dockerfile apps have a default 'cmd' process type. 1277 <limit> 1278 The limit to apply to the process type. By default, this is set to --memory. 1279 You can only set one type of limit per call. 1280 1281 With --memory, units are represented in Bytes (B), Kilobytes (K), Megabytes 1282 (M), or Gigabytes (G). For example, `deis limit:set cmd=1G` will restrict all 1283 "cmd" processes to a maximum of 1 Gigabyte of memory each. 1284 1285 With --cpu, units are represented in the number of cpu shares. For example, 1286 `deis limit:set --cpu cmd=1024` will restrict all "cmd" processes to a 1287 maximum of 1024 cpu shares. 1288 1289 Options: 1290 -a --app=<app> 1291 the uniquely identifiable name for the application. 1292 -c --cpu 1293 limits cpu shares. 1294 -m --memory 1295 limits memory. [default: true] 1296 """ 1297 app = args.get('--app') 1298 if not app: 1299 app = self._session.app 1300 body = {} 1301 # see if cpu shares are being specified, otherwise default to memory 1302 target = 'cpu' if args.get('--cpu') else 'memory' 1303 body[target] = json.dumps(dictify(args['<type>=<limit>'])) 1304 sys.stdout.write('Applying limits... ') 1305 sys.stdout.flush() 1306 try: 1307 progress = TextProgress() 1308 progress.start() 1309 response = self._dispatch('post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1310 finally: 1311 progress.cancel() 1312 progress.join() 1313 if response.status_code == requests.codes.created: 1314 version = response.headers['x-deis-release'] 1315 self._logger.info("done, v{}\n".format(version)) 1316 1317 self._print_limits(app, response.json()) 1318 else: 1319 raise ResponseError(response) 1320 1321 def limits_unset(self, args): 1322 """ 1323 Unsets resource limits for an application. 1324 1325 Usage: deis limits:unset [options] [--memory | --cpu] <type>... 1326 1327 Arguments: 1328 <type> 1329 the process type as defined in your Procfile, such as 'web' or 'worker'. 1330 Note that Dockerfile apps have a default 'cmd' process type. 1331 1332 Options: 1333 -a --app=<app> 1334 the uniquely identifiable name for the application. 1335 -c --cpu 1336 limits cpu shares. 1337 -m --memory 1338 limits memory. [default: true] 1339 """ 1340 app = args.get('--app') 1341 if not app: 1342 app = self._session.app 1343 values = {} 1344 for k in args.get('<type>'): 1345 values[k] = None 1346 body = {} 1347 # see if cpu shares are being specified, otherwise default to memory 1348 target = 'cpu' if args.get('--cpu') else 'memory' 1349 body[target] = json.dumps(values) 1350 sys.stdout.write('Applying limits... ') 1351 sys.stdout.flush() 1352 try: 1353 progress = TextProgress() 1354 progress.start() 1355 response = self._dispatch('post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1356 finally: 1357 progress.cancel() 1358 progress.join() 1359 if response.status_code == requests.codes.created: 1360 version = response.headers['x-deis-release'] 1361 self._logger.info("done, v{}\n".format(version)) 1362 self._print_limits(app, response.json()) 1363 else: 1364 raise ResponseError(response) 1365 1366 def _print_limits(self, app, config): 1367 self._logger.info("=== {} Limits".format(app)) 1368 1369 def write(d): 1370 items = d.items() 1371 if len(items) == 0: 1372 self._logger.info('Unlimited') 1373 return 1374 keys = sorted(d) 1375 width = max(map(len, keys)) + 5 1376 for k in keys: 1377 v = d[k] 1378 self._logger.info(("{k:<" + str(width) + "} {v}").format(**locals())) 1379 1380 self._logger.info("\n--- Memory") 1381 write(config.get('memory', '{}')) 1382 self._logger.info("\n--- CPU") 1383 write(config.get('cpu', '{}')) 1384 1385 def ps(self, args): 1386 """ 1387 Valid commands for processes: 1388 1389 ps:list list application processes 1390 ps:scale scale processes (e.g. web=4 worker=2) 1391 1392 Use `deis help [command]` to learn more. 1393 """ 1394 sys.argv[1] = 'ps:list' 1395 args = docopt(self.ps_list.__doc__) 1396 return self.ps_list(args) 1397 1398 def ps_list(self, args, app=None): 1399 """ 1400 Lists processes servicing an application. 1401 1402 Usage: deis ps:list [options] 1403 1404 Options: 1405 -a --app=<app> 1406 the uniquely identifiable name for the application. 1407 """ 1408 if not app: 1409 app = args.get('--app') 1410 if not app: 1411 app = self._session.app 1412 response = self._dispatch('get', 1413 "/v1/apps/{}/containers".format(app)) 1414 if response.status_code != requests.codes.ok: 1415 raise ResponseError(response) 1416 processes = response.json() 1417 self._logger.info("=== {} Processes\n".format(app)) 1418 c_map = {} 1419 for item in processes['results']: 1420 c_map.setdefault(item['type'], []).append(item) 1421 for c_type in c_map.keys(): 1422 self._logger.info("--- {c_type}: ".format(**locals())) 1423 for c in c_map[c_type]: 1424 self._logger.info("{type}.{num} {state} ({release})".format(**c)) 1425 self._logger.info('') 1426 1427 def ps_scale(self, args): 1428 """ 1429 Scales an application's processes by type. 1430 1431 Usage: deis ps:scale <type>=<num>... [options] 1432 1433 Arguments: 1434 <type> 1435 the process name as defined in your Procfile, such as 'web' or 'worker'. 1436 Note that Dockerfile apps have a default 'cmd' process type. 1437 <num> 1438 the number of processes. 1439 1440 Options: 1441 -a --app=<app> 1442 the uniquely identifiable name for the application. 1443 """ 1444 app = args.get('--app') 1445 if not app: 1446 app = self._session.app 1447 body = {} 1448 for type_num in args.get('<type>=<num>'): 1449 typ, count = type_num.split('=') 1450 body.update({typ: int(count)}) 1451 scaling_cmd = 'Scaling processes... but first, {}!\n'.format( 1452 os.environ.get('DEIS_DRINK_OF_CHOICE', 'coffee')) 1453 sys.stdout.write(scaling_cmd) 1454 sys.stdout.flush() 1455 try: 1456 progress = TextProgress() 1457 progress.start() 1458 before = time.time() 1459 response = self._dispatch('post', 1460 "/v1/apps/{}/scale".format(app), 1461 json.dumps(body)) 1462 finally: 1463 progress.cancel() 1464 progress.join() 1465 if response.status_code == requests.codes.no_content: 1466 self._logger.info('done in {}s'.format(int(time.time() - before))) 1467 self.ps_list({}, app) 1468 else: 1469 raise ResponseError(response) 1470 1471 def tags(self, args): 1472 """ 1473 Valid commands for tags: 1474 1475 tags:list list tags for an app 1476 tags:set set tags for an app 1477 tags:unset unset tags for an app 1478 1479 Use `deis help [command]` to learn more. 1480 """ 1481 sys.argv[1] = 'tags:list' 1482 args = docopt(self.tags_list.__doc__) 1483 return self.tags_list(args) 1484 1485 def tags_list(self, args): 1486 """ 1487 Lists tags for an application. 1488 1489 Usage: deis tags:list [options] 1490 1491 Options: 1492 -a --app=<app> 1493 the uniquely identifiable name of the application. 1494 """ 1495 app = args.get('--app') 1496 if not app: 1497 app = self._session.app 1498 response = self._dispatch('get', "/v1/apps/{}/config".format(app)) 1499 if response.status_code == requests.codes.ok: 1500 self._print_tags(app, response.json()) 1501 else: 1502 raise ResponseError(response) 1503 1504 def tags_set(self, args): 1505 """ 1506 Sets tags for an application. 1507 1508 A tag is a key/value pair used to tag an application's containers and is passed to the scheduler. 1509 This is often used to restrict workloads to specific hosts matching the scheduler-configured metadata. 1510 1511 Usage: deis tags:set [options] <key>=<value>... 1512 1513 Arguments: 1514 <key> the tag key, for example: "environ" or "rack" 1515 <value> the tag value, for example: "prod" or "1" 1516 1517 Options: 1518 -a --app=<app> 1519 the uniquely identifiable name for the application. 1520 """ 1521 app = args.get('--app') 1522 if not app: 1523 app = self._session.app 1524 body = {} 1525 body['tags'] = json.dumps(dictify(args['<key>=<value>'])) 1526 sys.stdout.write('Applying tags... ') 1527 sys.stdout.flush() 1528 try: 1529 progress = TextProgress() 1530 progress.start() 1531 response = self._dispatch('post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1532 finally: 1533 progress.cancel() 1534 progress.join() 1535 if response.status_code == requests.codes.created: 1536 version = response.headers['x-deis-release'] 1537 self._logger.info("done, v{}\n".format(version)) 1538 1539 self._print_tags(app, response.json()) 1540 else: 1541 raise ResponseError(response) 1542 1543 def tags_unset(self, args): 1544 """ 1545 Unsets tags for an application. 1546 1547 Usage: deis tags:unset [options] <key>... 1548 1549 Arguments: 1550 <key> the tag key to unset, for example: "environ" or "rack" 1551 1552 Options: 1553 -a --app=<app> 1554 the uniquely identifiable name for the application. 1555 """ 1556 app = args.get('--app') 1557 if not app: 1558 app = self._session.app 1559 values = {} 1560 for k in args.get('<key>'): 1561 values[k] = None 1562 body = {} 1563 body['tags'] = json.dumps(values) 1564 sys.stdout.write('Applying tags... ') 1565 sys.stdout.flush() 1566 try: 1567 progress = TextProgress() 1568 progress.start() 1569 response = self._dispatch('post', "/v1/apps/{}/config".format(app), json.dumps(body)) 1570 finally: 1571 progress.cancel() 1572 progress.join() 1573 if response.status_code == requests.codes.created: 1574 version = response.headers['x-deis-release'] 1575 self._logger.info("done, v{}\n".format(version)) 1576 self._print_tags(app, response.json()) 1577 else: 1578 raise ResponseError(response) 1579 1580 def _print_tags(self, app, config): 1581 items = config['tags'] 1582 self._logger.info("=== {} Tags".format(app)) 1583 if len(items) == 0: 1584 self._logger.info('No tags defined') 1585 return 1586 keys = sorted(items) 1587 width = max(map(len, keys)) + 5 1588 for k in keys: 1589 v = items[k] 1590 self._logger.info(("{k:<" + str(width) + "} {v}").format(**locals())) 1591 1592 def keys(self, args): 1593 """ 1594 Valid commands for SSH keys: 1595 1596 keys:list list SSH keys for the logged in user 1597 keys:add add an SSH key 1598 keys:remove remove an SSH key 1599 1600 Use `deis help [command]` to learn more. 1601 """ 1602 sys.argv[1] = 'keys:list' 1603 args = docopt(self.keys_list.__doc__) 1604 return self.keys_list(args) 1605 1606 def keys_add(self, args): 1607 """ 1608 Adds SSH keys for the logged in user. 1609 1610 Usage: deis keys:add [<key>] 1611 1612 Arguments: 1613 <key> 1614 a local file path to an SSH public key used to push application code. 1615 """ 1616 path = args.get('<key>') 1617 if not path: 1618 selected_key = self._ask_pubkey_interactively() 1619 else: 1620 # check the specified key format 1621 selected_key = self._parse_key(path) 1622 if not selected_key: 1623 self._logger.error("usage: deis keys:add [<key>]") 1624 return 1625 # Upload the key to Deis 1626 body = { 1627 'id': selected_key.id, 1628 'public': "{} {}".format(selected_key.type, selected_key.str) 1629 } 1630 sys.stdout.write("Uploading {} to Deis...".format(selected_key.id)) 1631 sys.stdout.flush() 1632 response = self._dispatch('post', '/v1/keys', json.dumps(body)) 1633 if response.status_code == requests.codes.created: 1634 self._logger.info('done') 1635 else: 1636 raise ResponseError(response) 1637 1638 def _parse_key(self, path): 1639 """Parse an SSH public key path into a Key namedtuple.""" 1640 Key = namedtuple('Key', 'path name type str comment id') 1641 name = path.split(os.path.sep)[-1] 1642 with open(path) as f: 1643 data = f.read() 1644 match = re.match(r'^(ssh-...|ecdsa-[^ ]+) ([^ ]+) ?(.*)', 1645 data) 1646 if not match: 1647 self._logger.error("Could not parse SSH public key {0}".format(name)) 1648 sys.exit(1) 1649 key_type, key_str, key_comment = match.groups() 1650 if key_comment: 1651 key_id = key_comment 1652 else: 1653 key_id = name.replace('.pub', '') 1654 return Key(path, name, key_type, key_str, key_comment, key_id) 1655 1656 def _ask_pubkey_interactively(self): 1657 # find public keys and prompt the user to pick one 1658 ssh_dir = os.path.expanduser('~/.ssh') 1659 pubkey_paths = glob.glob(os.path.join(ssh_dir, '*.pub')) 1660 if not pubkey_paths: 1661 self._logger.error('No SSH public keys found') 1662 return 1663 pubkeys_list = [self._parse_key(k) for k in pubkey_paths] 1664 self._logger.info('Found the following SSH public keys:') 1665 for i, key_ in enumerate(pubkeys_list): 1666 self._logger.info("{}) {} {}".format(i + 1, key_.name, key_.comment)) 1667 self._logger.info("0) Enter path to pubfile (or use keys:add <key_path>) ") 1668 inp = raw_input('Which would you like to use with Deis? ') 1669 try: 1670 if int(inp) != 0: 1671 selected_key = pubkeys_list[int(inp) - 1] 1672 else: 1673 selected_key_path = raw_input('Enter the path to the pubkey file: ') 1674 selected_key = self._parse_key(os.path.expanduser(selected_key_path)) 1675 except: 1676 self._logger.info('Aborting') 1677 return 1678 return selected_key 1679 1680 def keys_list(self, args): 1681 """ 1682 Lists SSH keys for the logged in user. 1683 1684 Usage: deis keys:list 1685 """ 1686 response = self._dispatch('get', '/v1/keys') 1687 if response.status_code == requests.codes.ok: 1688 data = response.json() 1689 if data['count'] == 0: 1690 self._logger.info('No keys found') 1691 return 1692 self._logger.info("=== {owner} Keys".format(**data['results'][0])) 1693 for key in data['results']: 1694 public = key['public'] 1695 self._logger.info("{0} {1}...{2}".format( 1696 key['id'], public[0:16], public[-10:])) 1697 else: 1698 raise ResponseError(response) 1699 1700 def keys_remove(self, args): 1701 """ 1702 Removes an SSH key for the logged in user. 1703 1704 Usage: deis keys:remove <key> 1705 1706 Arguments: 1707 <key> 1708 the SSH public key to revoke source code push access. 1709 """ 1710 key = args.get('<key>') 1711 sys.stdout.write("Removing {} SSH Key... ".format(key)) 1712 sys.stdout.flush() 1713 response = self._dispatch('delete', "/v1/keys/{}".format(key)) 1714 if response.status_code == requests.codes.no_content: 1715 self._logger.info('done') 1716 else: 1717 raise ResponseError(response) 1718 1719 def perms(self, args): 1720 """ 1721 Valid commands for perms: 1722 1723 perms:list list permissions granted on an app 1724 perms:create create a new permission for a user 1725 perms:delete delete a permission for a user 1726 1727 Use `deis help perms:[command]` to learn more. 1728 """ 1729 sys.argv[1] = 'perms:list' 1730 args = docopt(self.perms_list.__doc__) 1731 return self.perms_list(args) 1732 1733 def perms_list(self, args): 1734 """ 1735 Lists all users with permission to use an app, or lists all users with system 1736 administrator privileges. 1737 1738 Usage: deis perms:list [-a --app=<app>|--admin] 1739 1740 Options: 1741 -a --app=<app> 1742 lists all users with permission to <app>. <app> is the uniquely identifiable name 1743 for the application. 1744 1745 --admin 1746 lists all users with system administrator privileges. 1747 """ 1748 app, url = self._parse_perms_args(args) 1749 response = self._dispatch('get', url) 1750 if response.status_code == requests.codes.ok: 1751 self._logger.info(json.dumps(response.json(), indent=2)) 1752 else: 1753 raise ResponseError(response) 1754 1755 def perms_create(self, args): 1756 """ 1757 Gives another user permission to use an app, or gives another user 1758 system administrator privileges. 1759 1760 Usage: deis perms:create <username> [-a --app=<app>|--admin] 1761 1762 Arguments: 1763 <username> 1764 the name of the new user. 1765 1766 Options: 1767 -a --app=<app> 1768 grants <username> permission to use <app>. <app> is the uniquely identifiable name 1769 for the application. 1770 1771 --admin 1772 grants <username> system administrator privileges. 1773 """ 1774 app, url = self._parse_perms_args(args) 1775 username = args.get('<username>') 1776 body = {'username': username} 1777 if app: 1778 msg = "Adding {} to {} collaborators... ".format(username, app) 1779 else: 1780 msg = "Adding {} to system administrators... ".format(username) 1781 sys.stdout.write(msg) 1782 sys.stdout.flush() 1783 response = self._dispatch('post', url, json.dumps(body)) 1784 if response.status_code == requests.codes.created: 1785 self._logger.info('done') 1786 else: 1787 raise ResponseError(response) 1788 1789 def perms_delete(self, args): 1790 """ 1791 Revokes another user's permission to use an app, or revokes another user's system 1792 administrator privileges. 1793 1794 Usage: deis perms:delete <username> [-a --app=<app>|--admin] 1795 1796 Arguments: 1797 <username> 1798 the name of the user. 1799 1800 Options: 1801 -a --app=<app> 1802 revokes <username> permission to use <app>. <app> is the uniquely identifiable name 1803 for the application. 1804 1805 --admin 1806 revokes <username> system administrator privileges. 1807 """ 1808 app, url = self._parse_perms_args(args) 1809 username = args.get('<username>') 1810 url = "{}/{}".format(url, username) 1811 if app: 1812 msg = "Removing {} from {} collaborators... ".format(username, app) 1813 else: 1814 msg = "Remove {} from system administrators... ".format(username) 1815 sys.stdout.write(msg) 1816 sys.stdout.flush() 1817 response = self._dispatch('delete', url) 1818 if response.status_code == requests.codes.no_content: 1819 self._logger.info('done') 1820 else: 1821 raise ResponseError(response) 1822 1823 def _parse_perms_args(self, args): 1824 app = args.get('--app'), 1825 admin = args.get('--admin') 1826 if admin: 1827 app = None 1828 url = '/v1/admin/perms' 1829 else: 1830 app = app[0] or self._session.app 1831 url = "/v1/apps/{}/perms".format(app) 1832 return app, url 1833 1834 def releases(self, args): 1835 """ 1836 Valid commands for releases: 1837 1838 releases:list list an application's release history 1839 releases:info print information about a specific release 1840 releases:rollback return to a previous release 1841 1842 Use `deis help [command]` to learn more. 1843 """ 1844 sys.argv[1] = 'releases:list' 1845 args = docopt(self.releases_list.__doc__) 1846 return self.releases_list(args) 1847 1848 def releases_info(self, args): 1849 """ 1850 Prints info about a particular release. 1851 1852 Usage: deis releases:info <version> [options] 1853 1854 Arguments: 1855 <version> 1856 the release of the application, such as 'v1'. 1857 1858 Options: 1859 -a --app=<app> 1860 the uniquely identifiable name for the application. 1861 """ 1862 version = args.get('<version>') 1863 if not version.startswith('v'): 1864 version = 'v' + version 1865 app = args.get('--app') 1866 if not app: 1867 app = self._session.app 1868 response = self._dispatch( 1869 'get', "/v1/apps/{app}/releases/{version}".format(**locals())) 1870 if response.status_code == requests.codes.ok: 1871 self._logger.info(json.dumps(response.json(), indent=2)) 1872 else: 1873 raise ResponseError(response) 1874 1875 def releases_list(self, args): 1876 """ 1877 Lists release history for an application. 1878 1879 Usage: deis releases:list [options] 1880 1881 Options: 1882 -a --app=<app> 1883 the uniquely identifiable name for the application. 1884 """ 1885 app = args.get('--app') 1886 if not app: 1887 app = self._session.app 1888 response = self._dispatch('get', "/v1/apps/{app}/releases".format(**locals())) 1889 if response.status_code == requests.codes.ok: 1890 self._logger.info("=== {} Releases".format(app)) 1891 data = response.json() 1892 for item in data['results']: 1893 item['created'] = readable_datetime(item['created']) 1894 self._logger.info("v{version:<6} {created:<24} {summary}".format(**item)) 1895 else: 1896 raise ResponseError(response) 1897 1898 def releases_rollback(self, args): 1899 """ 1900 Rolls back to a previous application release. 1901 1902 Usage: deis releases:rollback [<version>] [options] 1903 1904 Arguments: 1905 <version> 1906 the release of the application, such as 'v1'. 1907 1908 Options: 1909 -a --app=<app> 1910 the uniquely identifiable name of the application. 1911 """ 1912 app = args.get('--app') 1913 if not app: 1914 app = self._session.app 1915 version = args.get('<version>') 1916 if version: 1917 if version.startswith('v'): 1918 version = version[1:] 1919 body = {'version': int(version)} 1920 else: 1921 body = {} 1922 url = "/v1/apps/{app}/releases/rollback".format(**locals()) 1923 if version: 1924 sys.stdout.write('Rolling back to v{version}... '.format(**locals())) 1925 else: 1926 sys.stdout.write('Rolling back one release... ') 1927 sys.stdout.flush() 1928 try: 1929 progress = TextProgress() 1930 progress.start() 1931 response = self._dispatch('post', url, json.dumps(body)) 1932 finally: 1933 progress.cancel() 1934 progress.join() 1935 if response.status_code == requests.codes.created: 1936 new_version = response.json()['version'] 1937 self._logger.info("done, v{}".format(new_version)) 1938 else: 1939 raise ResponseError(response) 1940 1941 def shortcuts(self, args): 1942 """ 1943 Shows valid shortcuts for client commands. 1944 1945 Usage: deis shortcuts 1946 """ 1947 self._logger.info('Valid shortcuts are:\n') 1948 for shortcut, command in SHORTCUTS.items(): 1949 if ':' not in shortcut: 1950 self._logger.info("{:<10} -> {}".format(shortcut, command)) 1951 self._logger.info('\nUse `deis help [command]` to learn more') 1952 1953 SHORTCUTS = OrderedDict([ 1954 ('create', 'apps:create'), 1955 ('destroy', 'apps:destroy'), 1956 ('info', 'apps:info'), 1957 ('login', 'auth:login'), 1958 ('logout', 'auth:logout'), 1959 ('logs', 'apps:logs'), 1960 ('open', 'apps:open'), 1961 ('passwd', 'auth:passwd'), 1962 ('pull', 'builds:create'), 1963 ('register', 'auth:register'), 1964 ('rollback', 'releases:rollback'), 1965 ('run', 'apps:run'), 1966 ('scale', 'ps:scale'), 1967 ('sharing', 'perms:list'), 1968 ('sharing:list', 'perms:list'), 1969 ('sharing:add', 'perms:create'), 1970 ('sharing:remove', 'perms:delete'), 1971 ('whoami', 'auth:whoami'), 1972 ]) 1973 1974 1975 def parse_args(cmd): 1976 """ 1977 Parses command-line args applying shortcuts and looking for help flags. 1978 """ 1979 if cmd == 'help': 1980 cmd = sys.argv[-1] 1981 help_flag = True 1982 else: 1983 cmd = sys.argv[1] 1984 help_flag = False 1985 # swap cmd with shortcut 1986 if cmd in SHORTCUTS: 1987 cmd = SHORTCUTS[cmd] 1988 # change the cmdline arg itself for docopt 1989 if not help_flag: 1990 sys.argv[1] = cmd 1991 else: 1992 sys.argv[2] = cmd 1993 # convert : to _ for matching method names and docstrings 1994 if ':' in cmd: 1995 cmd = '_'.join(cmd.split(':')) 1996 return cmd, help_flag 1997 1998 1999 def _dispatch_cmd(method, args): 2000 logger = logging.getLogger(__name__) 2001 if args.get('--app'): 2002 args['--app'] = args['--app'].lower() 2003 try: 2004 method(args) 2005 except requests.exceptions.ConnectionError as err: 2006 logger.error("Couldn't connect to the Deis Controller. Make sure that the Controller URI is \ 2007 correct and the server is running.") 2008 sys.exit(1) 2009 except EnvironmentError as err: 2010 logger.error(err.args[0]) 2011 sys.exit(1) 2012 except ResponseError as err: 2013 resp = err.args[0] 2014 logger.error('{} {}'.format(resp.status_code, resp.reason)) 2015 try: 2016 msg = resp.json() 2017 if 'detail' in msg: 2018 msg = "Detail:\n{}".format(msg['detail']) 2019 except: 2020 msg = resp.text 2021 logger.info(msg) 2022 sys.exit(1) 2023 2024 2025 def _init_logger(): 2026 logger = logging.getLogger(__name__) 2027 handler = logging.StreamHandler(sys.stdout) 2028 # TODO: add a --debug flag 2029 logger.setLevel(logging.INFO) 2030 handler.setLevel(logging.INFO) 2031 logger.addHandler(handler) 2032 2033 2034 def main(): 2035 """ 2036 Create a client, parse the arguments received on the command line, and 2037 call the appropriate method on the client. 2038 """ 2039 _init_logger() 2040 cli = DeisClient() 2041 args = docopt(__doc__, version=__version__, 2042 options_first=True) 2043 cmd = args['<command>'] 2044 cmd, help_flag = parse_args(cmd) 2045 # print help if it was asked for 2046 if help_flag: 2047 if cmd != 'help' and cmd in dir(cli): 2048 print(trim(getattr(cli, cmd).__doc__)) 2049 return 2050 docopt(__doc__, argv=['--help']) 2051 # unless cmd needs to use sys.argv directly 2052 if hasattr(cli, cmd): 2053 method = getattr(cli, cmd) 2054 else: 2055 raise DocoptExit('Found no matching command, try `deis help`') 2056 # re-parse docopt with the relevant docstring 2057 docstring = trim(getattr(cli, cmd).__doc__) 2058 if 'Usage: ' in docstring: 2059 args.update(docopt(docstring)) 2060 # dispatch the CLI command 2061 _dispatch_cmd(method, args) 2062 2063 2064 if __name__ == '__main__': 2065 main() 2066 sys.exit(0)