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

     1  from contextlib import contextmanager
     2  from datetime import (
     3      datetime,
     4      timedelta,
     5  )
     6  import errno
     7  import json
     8  import logging
     9  import os
    10  import re
    11  import subprocess
    12  import socket
    13  import sys
    14  from time import (
    15      sleep,
    16      time,
    17  )
    18  from jujupy.utility import (
    19      ensure_deleted,
    20      ensure_dir,
    21      get_timeout_path,
    22      get_unit_public_ip,
    23      is_ipv6_address,
    24      print_now,
    25      qualified_model_name,
    26      quote,
    27      scoped_environ,
    28      skip_on_missing_file,
    29      temp_dir,
    30      temp_yaml_file,
    31      until_timeout
    32  )
    33  
    34  # Imported for other call sites to use.
    35  __all__ = [
    36      'ensure_deleted',
    37      'ensure_dir',
    38      'get_timeout_path',
    39      'get_unit_public_ip',
    40      'qualified_model_name',
    41      'quote',
    42      'scoped_environ',
    43      'skip_on_missing_file',
    44      'temp_dir',
    45      'temp_yaml_file',
    46  ]
    47  
    48  
    49  # Equivalent of socket.EAI_NODATA when using windows sockets
    50  # <https://msdn.microsoft.com/ms740668#WSANO_DATA>
    51  WSANO_DATA = 11004
    52  
    53  TEST_MODEL = 'test-tmp-env'
    54  
    55  log = logging.getLogger("utility")
    56  
    57  
    58  class PortTimeoutError(Exception):
    59      pass
    60  
    61  
    62  class LoggedException(BaseException):
    63      """Raised in place of an exception that has already been logged.
    64  
    65      This is a wrapper to avoid double-printing real Exceptions while still
    66      unwinding the stack appropriately.
    67      """
    68  
    69      def __init__(self, exception):
    70          self.exception = exception
    71  
    72  
    73  class JujuAssertionError(AssertionError):
    74      """Exception for juju assertion failures."""
    75  
    76  
    77  def _clean_dir(maybe_dir):
    78      """Pseudo-type that validates an argument to be a clean directory path.
    79  
    80      For safety, this function will not attempt to remove existing directory
    81      contents but will just report a warning.
    82      """
    83      try:
    84          contents = os.listdir(maybe_dir)
    85      except OSError as e:
    86          if e.errno == errno.ENOENT:
    87              # we don't raise this error due to tests abusing /tmp/logs
    88              logging.warning('Not a directory {}'.format(maybe_dir))
    89          if e.errno == errno.EEXIST:
    90              logging.warnings('Directory {} already exists'.format(maybe_dir))
    91      else:
    92          if contents and contents != ["empty"]:
    93              logging.warning(
    94                  'Directory {!r} has existing contents.'.format(maybe_dir))
    95      return maybe_dir
    96  
    97  
    98  def as_literal_address(address):
    99      """Returns address in form suitable for embedding in URL or similar.
   100  
   101      In practice, this just puts square brackets round IPv6 addresses which
   102      avoids conflict with port seperators and other uses of colons.
   103      """
   104      if is_ipv6_address(address):
   105          return address.join("[]")
   106      return address
   107  
   108  
   109  def wait_for_port(host, port, closed=False, timeout=30):
   110      family = socket.AF_INET6 if is_ipv6_address(host) else socket.AF_INET
   111      for remaining in until_timeout(timeout):
   112          try:
   113              addrinfo = socket.getaddrinfo(host, port, family,
   114                                            socket.SOCK_STREAM)
   115          except socket.error as e:
   116              if e.errno not in (socket.EAI_NODATA, WSANO_DATA):
   117                  raise
   118              if closed:
   119                  return
   120              else:
   121                  continue
   122          sockaddr = addrinfo[0][4]
   123          # Treat Azure messed-up address lookup as a closed port.
   124          if sockaddr[0] == '0.0.0.0':
   125              if closed:
   126                  return
   127              else:
   128                  continue
   129          conn = socket.socket(*addrinfo[0][:3])
   130          conn.settimeout(max(remaining or 0, 5))
   131          try:
   132              conn.connect(sockaddr)
   133          except socket.timeout:
   134              if closed:
   135                  return
   136          except socket.error as e:
   137              if e.errno not in (errno.ECONNREFUSED, errno.ENETUNREACH,
   138                                 errno.ETIMEDOUT, errno.EHOSTUNREACH):
   139                  raise
   140              if closed:
   141                  return
   142          except socket.gaierror as e:
   143              print_now(str(e))
   144          except Exception as e:
   145              print_now('Unexpected {!r}: {}'.format((type(e), e)))
   146              raise
   147          else:
   148              conn.close()
   149              if not closed:
   150                  return
   151              sleep(1)
   152      raise PortTimeoutError('Timed out waiting for port.')
   153  
   154  
   155  def get_revision_build(build_info):
   156      for action in build_info['actions']:
   157          if 'parameters' in action:
   158              for parameter in action['parameters']:
   159                  if parameter['name'] == 'revision_build':
   160                      return parameter['value']
   161  
   162  
   163  def get_winrm_certs():
   164      """"Returns locations of key and cert files for winrm in cloud-city."""
   165      home = os.environ['HOME']
   166      return (
   167          os.path.join(home, 'cloud-city/winrm_client_cert.key'),
   168          os.path.join(home, 'cloud-city/winrm_client_cert.pem'),
   169      )
   170  
   171  
   172  def s3_cmd(params, drop_output=False):
   173      s3cfg_path = os.path.join(
   174          os.environ['HOME'], 'cloud-city/juju-qa.s3cfg')
   175      command = ['s3cmd', '-c', s3cfg_path, '--no-progress'] + params
   176      if drop_output:
   177          return subprocess.check_call(
   178              command, stdout=open('/dev/null', 'w'))
   179      else:
   180          return subprocess.check_output(command)
   181  
   182  
   183  def _get_test_name_from_filename():
   184      try:
   185          calling_file = sys._getframe(2).f_back.f_globals['__file__']
   186          return os.path.splitext(os.path.basename(calling_file))[0]
   187      except:
   188          return 'unknown_test'
   189  
   190  
   191  def generate_default_clean_dir(temp_env_name):
   192      """Creates a new unique directory for logging and returns name"""
   193      logging.debug('Environment {}'.format(temp_env_name))
   194      test_name = temp_env_name.split('-')[0]
   195      timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
   196      log_dir = os.path.join('/tmp', test_name, 'logs', timestamp)
   197  
   198      try:
   199          os.makedirs(log_dir)
   200          logging.info('Created logging directory {}'.format(log_dir))
   201      except OSError as e:
   202          if e.errno == errno.EEXIST:
   203              logging.warn('"Directory {} already exists'.format(log_dir))
   204          else:
   205              raise('Failed to create logging directory: {} ' +
   206                    log_dir +
   207                    '. Please specify empty folder or try again')
   208      return log_dir
   209  
   210  
   211  def _generate_default_temp_env_name():
   212      """Creates a new unique name for environment and returns the name"""
   213      # we need to sanitize the name
   214      timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
   215      test_name = re.sub('[^a-zA-Z]', '', _get_test_name_from_filename())
   216      return '{}-{}-temp-env'.format(test_name, timestamp)
   217  
   218  
   219  def _to_deadline(timeout):
   220      return datetime.utcnow() + timedelta(seconds=int(timeout))
   221  
   222  
   223  def add_arg_juju_bin(parser):
   224      parser.add_argument('juju_bin', nargs='?',
   225                          help='Full path to the Juju binary. By default, this'
   226                          ' will use $PATH/juju',
   227                          default=None)
   228  
   229  
   230  def add_basic_testing_arguments(
   231          parser, using_jes=False, deadline=True, env=True, existing=True):
   232      """Returns the parser loaded with basic testing arguments.
   233  
   234      The basic testing arguments, used in conjuction with boot_context ensures
   235      a test can be run in any supported substrate in parallel.
   236  
   237      This helper adds 4 positional arguments that defines the minimum needed
   238      to run a test script.
   239  
   240      These arguments (env, juju_bin, logs, temp_env_name) allow you to specify
   241      specifics for which env, juju binary, which folder for logging and an
   242      environment name for your test respectively.
   243  
   244      There are many optional args that either update the env's config or
   245      manipulate the juju command line options to test in controlled situations
   246      or in uncommon substrates: --debug, --verbose, --agent-url, --agent-stream,
   247      --series, --bootstrap-host, --machine, --keep-env. If not using_jes, the
   248      --upload-tools arg will also be added.
   249  
   250      :param parser: an ArgumentParser.
   251      :param using_jes: whether args should be tailored for JES testing.
   252      :param deadline: If true, support the --timeout option and convert to a
   253          deadline.
   254      :param existing: If true will supply the 'existing' argument to allow
   255          running on an existing bootstrapped controller.
   256      """
   257  
   258      # Optional postional arguments
   259      if env:
   260          parser.add_argument(
   261              'env', nargs='?',
   262              help='The juju environment to base the temp test environment on.',
   263              default='lxd')
   264      add_arg_juju_bin(parser)
   265      parser.add_argument('logs', nargs='?', type=_clean_dir,
   266                          help='A directory in which to store logs. By default,'
   267                          ' this will use the current directory',
   268                          default=None)
   269      parser.add_argument('temp_env_name', nargs='?',
   270                          help='A temporary test environment name. By default, '
   271                          ' this will generate an enviroment name using the '
   272                          ' timestamp and testname. '
   273                          ' test_name_timestamp_temp_env',
   274                          default=_generate_default_temp_env_name())
   275  
   276      # Optional keyword arguments.
   277      parser.add_argument('--debug', action='store_true',
   278                          help='Pass --debug to Juju.')
   279      parser.add_argument('--verbose', action='store_const',
   280                          default=logging.INFO, const=logging.DEBUG,
   281                          help='Verbose test harness output.')
   282      parser.add_argument('--region', help='Override environment region.')
   283      parser.add_argument('--to', default=None,
   284                          help='Place the controller at a location.')
   285      parser.add_argument('--agent-url', action='store', default=None,
   286                          help='URL for retrieving agent binaries.')
   287      parser.add_argument('--agent-stream', action='store', default=None,
   288                          help='Stream for retrieving agent binaries.')
   289      parser.add_argument('--series', action='store', default=None,
   290                          help='Name of the Ubuntu series to use.')
   291      if not using_jes:
   292          parser.add_argument('--upload-tools', action='store_true',
   293                              help='upload local version of tools to bootstrap.')
   294      parser.add_argument('--bootstrap-host',
   295                          help='The host to use for bootstrap.')
   296      parser.add_argument('--machine', help='A machine to add or when used with '
   297                          'KVM based MaaS, a KVM image to start.',
   298                          action='append', default=[])
   299      parser.add_argument('--keep-env', action='store_true',
   300                          help='Keep the Juju environment after the test'
   301                          ' completes.')
   302      parser.add_argument('--logging-config',
   303                          help="Override logging configuration for a deployment.",
   304                          default="<root>=INFO;unit=INFO")
   305      if existing:
   306          parser.add_argument(
   307              '--existing',
   308              action='store',
   309              default=None,
   310              const='current',
   311              nargs='?',
   312              help='Test using an existing bootstrapped controller. '
   313                   'If no controller name is provided defaults to using the '
   314                   'current selected controller.')
   315      if deadline:
   316          parser.add_argument('--timeout', dest='deadline', type=_to_deadline,
   317                              help="The script timeout, in seconds.")
   318      return parser
   319  
   320  
   321  # suppress nosetests
   322  add_basic_testing_arguments.__test__ = False
   323  
   324  
   325  def configure_logging(log_level):
   326      logging.basicConfig(
   327          level=log_level, format='%(asctime)s %(levelname)s %(message)s',
   328          datefmt='%Y-%m-%d %H:%M:%S')
   329  
   330  
   331  def get_candidates_path(root_dir):
   332      return os.path.join(root_dir, 'candidate')
   333  
   334  
   335  # GZ 2015-10-15: Paths returned in filesystem dependent order, may want sort?
   336  def find_candidates(root_dir, find_all=False):
   337      return (path for path, buildvars in _find_candidates(root_dir, find_all))
   338  
   339  
   340  def find_latest_branch_candidates(root_dir):
   341      """Return a list of one candidate per branch.
   342  
   343      :param root_dir: The root directory to find candidates from.
   344      """
   345      candidates = []
   346      for path, buildvars_path in _find_candidates(root_dir, find_all=False,
   347                                                   artifacts=True):
   348          with open(buildvars_path) as buildvars_file:
   349              buildvars = json.load(buildvars_file)
   350              candidates.append(
   351                  (buildvars['branch'], int(buildvars['revision_build']), path))
   352      latest = dict(
   353          (branch, (path, build)) for branch, build, path in sorted(candidates))
   354      return latest.values()
   355  
   356  
   357  def _find_candidates(root_dir, find_all=False, artifacts=False):
   358      candidates_path = get_candidates_path(root_dir)
   359      a_week_ago = time() - timedelta(days=7).total_seconds()
   360      for candidate_dir in os.listdir(candidates_path):
   361          if candidate_dir.endswith('-artifacts') != artifacts:
   362              continue
   363          candidate_path = os.path.join(candidates_path, candidate_dir)
   364          buildvars = os.path.join(candidate_path, 'buildvars.json')
   365          try:
   366              stat = os.stat(buildvars)
   367          except OSError as e:
   368              if e.errno in (errno.ENOENT, errno.ENOTDIR):
   369                  continue
   370              raise
   371          if not find_all and stat.st_mtime < a_week_ago:
   372              continue
   373          yield candidate_path, buildvars
   374  
   375  
   376  def get_deb_arch():
   377      """Get the debian machine architecture."""
   378      return subprocess.check_output(['dpkg', '--print-architecture']).strip()
   379  
   380  
   381  def extract_deb(package_path, directory):
   382      """Extract a debian package to a specified directory."""
   383      subprocess.check_call(['dpkg', '-x', package_path, directory])
   384  
   385  
   386  def run_command(command, dry_run=False, verbose=False):
   387      """Optionally execute a command and maybe print the output."""
   388      if verbose:
   389          print_now('Executing: {}'.format(command))
   390      if not dry_run:
   391          output = subprocess.check_output(command)
   392          if verbose:
   393              print_now(output)
   394  
   395  
   396  def log_and_wrap_exception(logger, exc):
   397      """Record exc details to logger and return wrapped in LoggedException."""
   398      logger.exception(exc)
   399      stdout = getattr(exc, 'output', None)
   400      stderr = getattr(exc, 'stderr', None)
   401      if stdout or stderr:
   402          logger.info('Output from exception:\nstdout:\n%s\nstderr:\n%s',
   403                      stdout, stderr)
   404      return LoggedException(exc)
   405  
   406  
   407  @contextmanager
   408  def logged_exception(logger):
   409      """\
   410      Record exceptions in managed context to logger and reraise LoggedException.
   411  
   412      Note that BaseException classes like SystemExit, GeneratorExit and
   413      LoggedException itself are not wrapped, except for KeyboardInterrupt.
   414      """
   415      try:
   416          yield
   417      except (Exception, KeyboardInterrupt) as e:
   418          raise log_and_wrap_exception(logger, e)
   419  
   420  
   421  def assert_dict_is_subset(sub_dict, super_dict):
   422      """Assert that every item in the sub_dict is in the super_dict.
   423  
   424      :raises JujuAssertionError: when sub_dict items are missing.
   425      :return: True when when sub_dict is a subset of super_dict
   426      """
   427      if not all(item in super_dict.items() for item in sub_dict.items()):
   428          raise JujuAssertionError(
   429              'Found: {} \nExpected: {}'.format(super_dict, sub_dict))
   430      return True
   431  
   432  
   433  def add_model(client):
   434      """Adds a model to the current juju environment then destroys it.
   435  
   436      Will raise an exception if the Juju does not deselect the current model.
   437      :param client: Jujupy ModelClient object
   438      """
   439      log.info('Adding model "{}" to current controller'.format(TEST_MODEL))
   440      new_client = client.add_model(TEST_MODEL)
   441      new_model = get_current_model(new_client)
   442      if new_model == TEST_MODEL:
   443          log.info('Current model and newly added model match')
   444      else:
   445          error = ('Juju failed to switch to new model after creation. '
   446                   'Expected {} got {}'.format(TEST_MODEL, new_model))
   447          raise JujuAssertionError(error)
   448      return new_client
   449  
   450  
   451  def get_current_model(client):
   452      """Gets the current model from Juju's list-models command.
   453  
   454      :param client: Jujupy ModelClient object
   455      :return: String name of current model
   456      """
   457      raw = list_models(client)
   458      try:
   459          return raw['current-model']
   460      except KeyError:
   461          log.warning('No model is currently selected.')
   462          return None
   463  
   464  
   465  def list_models(client):
   466      """List models.
   467      :param client: Jujupy ModelClient object
   468      :return: Dict of list-models command
   469      """
   470      try:
   471          raw = client.get_juju_output('list-models', '--format', 'json',
   472                                       include_e=False).decode('utf-8')
   473      except subprocess.CalledProcessError as e:
   474          log.error('Failed to list current models due to error: {}'.format(e))
   475          raise e
   476      return json.loads(raw)