github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/jujupy/backend.py (about)

     1  # This file is part of JujuPy, a library for driving the Juju CLI.
     2  # Copyright 2013-2017 Canonical Ltd.
     3  #
     4  # This program is free software: you can redistribute it and/or modify it
     5  # under the terms of the Lesser GNU General Public License version 3, as
     6  # published by the Free Software Foundation.
     7  #
     8  # This program is distributed in the hope that it will be useful, but WITHOUT
     9  # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
    10  # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.  See the Lesser
    11  # GNU General Public License for more details.
    12  #
    13  # You should have received a copy of the Lesser GNU General Public License
    14  # along with this program.  If not, see <http://www.gnu.org/licenses/>.
    15  
    16  import logging
    17  import json
    18  import os
    19  import pexpect
    20  import subprocess
    21  
    22  from contextlib import contextmanager
    23  from datetime import datetime
    24  
    25  from jujupy.exceptions import (
    26      CannotConnectEnv,
    27      NoActiveControllers,
    28      NoActiveModel,
    29      SoftDeadlineExceeded,
    30      )
    31  from jujupy.utility import (
    32      get_timeout_path,
    33      get_timeout_prefix,
    34      pause,
    35      quote,
    36      scoped_environ,
    37      )
    38  from jujupy.wait_condition import (
    39      CommandTime,
    40      )
    41  
    42  __metaclass__ = type
    43  
    44  # Python 2 and 3 compatibility
    45  try:
    46      argtype = basestring
    47  except NameError:
    48      argtype = str
    49  
    50  log = logging.getLogger("jujupy.backend")
    51  
    52  JUJU_DEV_FEATURE_FLAGS = 'JUJU_DEV_FEATURE_FLAGS'
    53  
    54  
    55  class JujuBackend:
    56      """A Juju backend referring to a specific juju 2 binary.
    57  
    58      Uses -m to specify models, uses JUJU_DATA to specify home directory.
    59      """
    60  
    61      _model_flag = '-m'
    62  
    63      def __init__(self, full_path, version, feature_flags, debug,
    64                   soft_deadline=None):
    65          self._version = version
    66          self._full_path = full_path
    67          self.feature_flags = feature_flags
    68          self.debug = debug
    69          self._timeout_path = get_timeout_path()
    70          self.juju_timings = []
    71          self.soft_deadline = soft_deadline
    72          self._ignore_soft_deadline = False
    73          # List of ModelClients, keep track of models added so we can remove
    74          # only those added during a test run (i.e. when using an existing
    75          # controller.)
    76          self._added_models = []
    77  
    78      def _now(self):
    79          return datetime.utcnow()
    80  
    81      @contextmanager
    82      def _check_timeouts(self):
    83          # If an exception occurred, we don't want to replace it with
    84          # SoftDeadlineExceeded.
    85          yield
    86          if self.soft_deadline is None or self._ignore_soft_deadline:
    87              return
    88          if self._now() > self.soft_deadline:
    89              raise SoftDeadlineExceeded()
    90  
    91      @contextmanager
    92      def ignore_soft_deadline(self):
    93          """Ignore the client deadline.  For cleanup code."""
    94          old_val = self._ignore_soft_deadline
    95          self._ignore_soft_deadline = True
    96          try:
    97              yield
    98          finally:
    99              self._ignore_soft_deadline = old_val
   100  
   101      def clone(self, full_path, version, debug, feature_flags):
   102          if version is None:
   103              version = self.version
   104          if full_path is None:
   105              full_path = self.full_path
   106          if debug is None:
   107              debug = self.debug
   108          result = self.__class__(full_path, version, feature_flags, debug,
   109                                  self.soft_deadline)
   110          # Each clone shares a reference to juju_timings allowing us to collect
   111          # all commands run during a test.
   112          result.juju_timings = self.juju_timings
   113  
   114          # Each clone shares a reference to _added_models to ensure we track any
   115          # added models regardless of the ModelClient that adds them.
   116          result._added_models = self._added_models
   117          return result
   118  
   119      def track_model(self, client):
   120          # Keep a reference to `client` for the lifetime of this backend (or
   121          # until it's untracked).
   122          self._added_models.append(client)
   123  
   124      def untrack_model(self, client):
   125          """Remove `client` from tracking. Silently fails if not present."""
   126          # No longer need to track this client for whatever reason.
   127          try:
   128              self._added_models.remove(client)
   129          except ValueError:
   130              log.debug(
   131                  'Attempted to remove client "{}" that was not tracked.'.format(
   132                      client.env.environment))
   133              pass
   134  
   135      @property
   136      def version(self):
   137          return self._version
   138  
   139      @property
   140      def full_path(self):
   141          return self._full_path
   142  
   143      @property
   144      def juju_name(self):
   145          return os.path.basename(self._full_path)
   146  
   147      @property
   148      def added_models(self):
   149          # Return a copy of the list so any modifications don't trip callees up.
   150          return list(self._added_models)
   151  
   152      def _get_attr_tuple(self):
   153          return (self._version, self._full_path, self.feature_flags,
   154                  self.debug, self.juju_timings)
   155  
   156      def __eq__(self, other):
   157          if type(self) != type(other):
   158              return False
   159          return self._get_attr_tuple() == other._get_attr_tuple()
   160  
   161      def shell_environ(self, used_feature_flags, juju_home):
   162          """Generate a suitable shell environment.
   163  
   164          Juju's directory must be in the PATH to support plugins.
   165          """
   166          env = dict(os.environ)
   167          if self.full_path is not None:
   168              env['PATH'] = '{}{}{}'.format(os.path.dirname(self.full_path),
   169                                            os.pathsep, env['PATH'])
   170          flags = self.feature_flags.intersection(used_feature_flags)
   171          feature_flag_string = env.get(JUJU_DEV_FEATURE_FLAGS, '')
   172          if feature_flag_string != '':
   173              flags.update(feature_flag_string.split(','))
   174          if flags:
   175              env[JUJU_DEV_FEATURE_FLAGS] = ','.join(sorted(flags))
   176          env['JUJU_DATA'] = juju_home
   177          return env
   178  
   179      def full_args(self, command, args, model, timeout):
   180          if model is not None:
   181              e_arg = (self._model_flag, model)
   182          else:
   183              e_arg = ()
   184          if timeout is None:
   185              prefix = ()
   186          else:
   187              prefix = get_timeout_prefix(timeout, self._timeout_path)
   188          logging = '--debug' if self.debug else '--show-log'
   189  
   190          # If args is a string, make it a tuple. This makes writing commands
   191          # with one argument a bit nicer.
   192          if isinstance(args, argtype):
   193              args = (args,)
   194          # we split the command here so that the caller can control where the -m
   195          # model flag goes.  Everything in the command string is put before the
   196          # -m flag.
   197          command = command.split()
   198          return (prefix + (self.juju_name, logging,) + tuple(command) + e_arg +
   199                  args)
   200  
   201      def juju(self, command, args, used_feature_flags,
   202               juju_home, model=None, check=True, timeout=None, extra_env=None,
   203               suppress_err=False):
   204          """Run a command under juju for the current environment.
   205  
   206          :return: Tuple rval, CommandTime rval being the commands exit code and
   207            a CommandTime object used for storing command timing data.
   208          """
   209          args = self.full_args(command, args, model, timeout)
   210          log.info(' '.join(args))
   211          env = self.shell_environ(used_feature_flags, juju_home)
   212          if extra_env is not None:
   213              env.update(extra_env)
   214          if check:
   215              call_func = subprocess.check_call
   216          else:
   217              call_func = subprocess.call
   218          # Mutate os.environ instead of supplying env parameter so Windows can
   219          # search env['PATH']
   220          stderr = subprocess.PIPE if suppress_err else None
   221          # Keep track of commands and how long the take.
   222          command_time = CommandTime(command, args, env)
   223          with scoped_environ(env):
   224              log.debug('Running juju with env: {}'.format(env))
   225              with self._check_timeouts():
   226                  rval = call_func(args, stderr=stderr)
   227          self.juju_timings.append(command_time)
   228          return rval, command_time
   229  
   230      def expect(self, command, args, used_feature_flags, juju_home, model=None,
   231                 timeout=None, extra_env=None):
   232          args = self.full_args(command, args, model, timeout)
   233          log.info(' '.join(args))
   234          env = self.shell_environ(used_feature_flags, juju_home)
   235          if extra_env is not None:
   236              env.update(extra_env)
   237          # pexpect.spawn expects a string. This is better than trying to extract
   238          # command + args from the returned tuple (as there could be an intial
   239          # timing command tacked on).
   240          command_string = ' '.join(quote(a) for a in args)
   241          with scoped_environ(env):
   242              return pexpect.spawn(command_string)
   243  
   244      @contextmanager
   245      def juju_async(self, command, args, used_feature_flags,
   246                     juju_home, model=None, timeout=None):
   247          full_args = self.full_args(command, args, model, timeout)
   248          log.info(' '.join(args))
   249          env = self.shell_environ(used_feature_flags, juju_home)
   250          # Mutate os.environ instead of supplying env parameter so Windows can
   251          # search env['PATH']
   252          with scoped_environ(env):
   253              with self._check_timeouts():
   254                  proc = subprocess.Popen(full_args)
   255          yield proc
   256          retcode = proc.wait()
   257          if retcode != 0:
   258              raise subprocess.CalledProcessError(retcode, full_args)
   259  
   260      def get_juju_output(self, command, args, used_feature_flags, juju_home,
   261                          model=None, timeout=None, user_name=None,
   262                          merge_stderr=False):
   263          args = self.full_args(command, args, model, timeout)
   264          env = self.shell_environ(used_feature_flags, juju_home)
   265          log.debug(args)
   266          # Mutate os.environ instead of supplying env parameter so
   267          # Windows can search env['PATH']
   268          with scoped_environ(env):
   269              proc = subprocess.Popen(
   270                  args, stdout=subprocess.PIPE, stdin=subprocess.PIPE,
   271                  stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE)
   272              with self._check_timeouts():
   273                  sub_output, sub_error = proc.communicate()
   274              log.debug(sub_output)
   275              if proc.returncode != 0:
   276                  log.debug(sub_error)
   277                  e = subprocess.CalledProcessError(
   278                      proc.returncode, args, sub_output)
   279                  e.stderr = sub_error
   280                  if sub_error and (
   281                      b'Unable to connect to environment' in sub_error or
   282                          b'MissingOrIncorrectVersionHeader' in sub_error or
   283                          b'307: Temporary Redirect' in sub_error):
   284                      raise CannotConnectEnv(e)
   285                  raise e
   286          return sub_output
   287  
   288      def get_active_model(self, juju_data_dir):
   289          """Determine the active model in a juju data dir."""
   290          try:
   291              current = json.loads(self.get_juju_output(
   292                  'models', ('--format', 'json'), set(),
   293                  juju_data_dir, model=None).decode('ascii'))
   294          except subprocess.CalledProcessError:
   295              raise NoActiveControllers(
   296                  'No active controller for {}'.format(juju_data_dir))
   297          try:
   298              return current['current-model']
   299          except KeyError:
   300              raise NoActiveModel('No active model for {}'.format(juju_data_dir))
   301  
   302      def get_active_controller(self, juju_data_dir):
   303          """Determine the active controller in a juju data dir."""
   304          try:
   305              current = json.loads(self.get_juju_output(
   306                  'controllers', ('--format', 'json'), set(),
   307                  juju_data_dir, model=None).decode('ascii'))
   308          except subprocess.CalledProcessError:
   309              raise NoActiveControllers(
   310                  'No active controller for {}'.format(juju_data_dir))
   311          try:
   312              return current['current-controller']
   313          except KeyError:
   314              raise NoActiveControllers(
   315                  'No active controller for {}'.format(juju_data_dir))
   316  
   317      def get_active_user(self, juju_data_dir, controller):
   318          """Determine the active user for a controller."""
   319          try:
   320              current = json.loads(self.get_juju_output(
   321                  'controllers', ('--format', 'json'), set(),
   322                  juju_data_dir, model=None).decode('ascii'))
   323          except subprocess.CalledProcessError:
   324              raise NoActiveControllers(
   325                  'No active controller for {}'.format(juju_data_dir))
   326          return current['controllers'][controller]['user']
   327  
   328      def pause(self, seconds):
   329          pause(seconds)