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)