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