github.com/SUSE/skuba@v1.4.17/ci/infra/testrunner/utils/utils.py (about)

     1  import glob
     2  import logging
     3  import os
     4  import shutil
     5  import subprocess
     6  from functools import wraps
     7  from threading import Thread
     8  
     9  import requests
    10  from timeout_decorator import timeout
    11  
    12  from utils.config import Constant
    13  from utils.format import Format
    14  
    15  logger = logging.getLogger('testrunner')
    16  
    17  _stepdepth = 0
    18  
    19  
    20  def step(f):
    21      @wraps(f)
    22      def wrapped(*args, **kwargs):
    23          global _stepdepth
    24          _stepdepth += 1
    25          logger.debug("{} entering {} {}".format(Format.DOT * _stepdepth, f.__name__,
    26                                                  f.__doc__ or ""))
    27          r = f(*args, **kwargs)
    28          logger.debug("{}  exiting {}".format(
    29              Format.DOT_EXIT * _stepdepth, f.__name__))
    30          _stepdepth -= 1
    31          return r
    32  
    33      return wrapped
    34  
    35  
    36  class Utils:
    37  
    38      def __init__(self, conf):
    39          self.conf = conf
    40  
    41      @staticmethod
    42      def chmod_recursive(directory, permissions):
    43          os.chmod(directory, permissions)
    44  
    45          for file in glob.glob(os.path.join(directory, "**/*"), recursive=True):
    46              try:
    47                  os.chmod(file, permissions)
    48              except Exception as ex:
    49                  logger.debug(ex)
    50  
    51      @staticmethod
    52      def cleanup_file(file):
    53          if os.path.exists(file):
    54              logger.debug(f"Cleaning up {file}")
    55              try:
    56                  try:
    57                      # Attempt to remove the file first, because a socket (e.g.
    58                      # ssh-agent) is not a file but has to be removed like one.
    59                      os.remove(file)
    60                  except IsADirectoryError:
    61                      shutil.rmtree(file)
    62              except Exception as ex:
    63                  logger.debug(ex)
    64          else:
    65              logger.debug(f"Nothing to clean up for {file}")
    66  
    67      @staticmethod
    68      def cleanup_files(files):
    69          """Remove any files or dirs in a list if they exist"""
    70          for file in files:
    71              Utils.cleanup_file(file)
    72  
    73      def ssh_cleanup(self):
    74          """Remove ssh sock files"""
    75          # TODO: also kill ssh agent here? maybe move pkill to kill_ssh_agent()?
    76          sock_file = self.conf.utils.ssh_sock
    77          sock_dir = os.path.dirname(sock_file)
    78          try:
    79              Utils.cleanup_file(sock_file)
    80              # also remove tempdir if it's empty afterwards
    81              if 0 == len(os.listdir(sock_dir)):
    82                  os.rmdir(sock_dir)
    83              else:
    84                  logger.warning(f"Dir {sock_dir} not empty; leaving it")
    85          except FileNotFoundError:
    86              pass
    87          except OSError as ex:
    88              logger.debug(ex)
    89  
    90      def collect_remote_logs(self, ip_address, logs, store_path):
    91          """
    92          Collect logs from a remote machine
    93          :param ip_address: (str) IP of the machine to collect the logs from
    94          :param logs: (dict: list) The different logs to collect {"files": [], "dirs": [], ""services": []}
    95          :param store_path: (str) Path to copy the logs to
    96          :return: (bool) True if there was an error while collecting the logs
    97          """
    98          logging_errors = False
    99  
   100          for log in logs.get("files", []):
   101              try:
   102                  self.scp_file(ip_address, log, store_path)
   103              except Exception as ex:
   104                  logger.debug(
   105                      f"Error while collecting {log} from {ip_address}\n {ex}")
   106                  logging_errors = True
   107  
   108          for log in logs.get("dirs", []):
   109              try:
   110                  self.rsync(ip_address, log, store_path)
   111              except Exception as ex:
   112                  logger.debug(
   113                      f"Error while collecting {log} from {ip_address}\n {ex}")
   114                  logging_errors = True
   115  
   116          for service in logs.get("services", []):
   117              try:
   118                  self.ssh_run(
   119                      ip_address, f"sudo journalctl -xeu {service} > {service}.log")
   120                  self.scp_file(ip_address, f"{service}.log", store_path)
   121              except Exception as ex:
   122                  logger.debug(
   123                      f"Error while collecting {service}.log from {ip_address}\n {ex}")
   124                  logging_errors = True
   125  
   126          return logging_errors
   127  
   128      def ssh_user(self):
   129          return self.conf.utils.ssh_user
   130  
   131      def authorized_keys(self):
   132          public_key_path = self.conf.utils.ssh_key + ".pub"
   133          os.chmod(self.conf.utils.ssh_key, 0o400)
   134  
   135          with open(public_key_path) as f:
   136              pubkey = f.read().strip()
   137          return pubkey
   138  
   139      def ssh_run(self, ipaddr, cmd):
   140          key_fn = self.conf.utils.ssh_key
   141          cmd = "ssh " + Constant.SSH_OPTS + " -i {key_fn} {username}@{ip} -- '{cmd}'".format(
   142              key_fn=key_fn, ip=ipaddr, cmd=cmd, username=self.ssh_user())
   143          return self.runshellcommand(cmd)
   144  
   145      def scp_file(self, ip_address, remote_file_path, local_file_path):
   146          """
   147          Copies a remote file from the given ip to the give path
   148          :param ip_address: (str) IP address of the node to copy from
   149          :param remote_file_path: (str) Path of the file to be copied
   150          :param local_file_path: (str) Path where to store the log
   151          :return:
   152          """
   153          cmd = (f"scp {Constant.SSH_OPTS} -i {self.conf.utils.ssh_key}"
   154                 f" {self.ssh_user()}@{ip_address}:{remote_file_path} {local_file_path}")
   155          self.runshellcommand(cmd)
   156  
   157      def rsync(self, ip_address, remote_dir_path, local_dir_path):
   158          """
   159          Copies a remote dir from the given ip to the give path
   160          :param ip_address: (str) IP address of the node to copy from
   161          :param remote_dir_path: (str) Path of the dir to be copied
   162          :param local_dir_path: (str) Path where to store the dir
   163          :return:
   164          """
   165          cmd = (f'rsync -avz --no-owner --no-perms -e "ssh {Constant.SSH_OPTS} -i {self.conf.utils.ssh_key}"  '
   166                 f'--rsync-path="sudo rsync" --ignore-missing-args {self.ssh_user()}@{ip_address}:{remote_dir_path} '
   167                 f'{local_dir_path}')
   168          self.runshellcommand(cmd)
   169  
   170      def runshellcommand(self, cmd, cwd=None, env={}, ignore_errors=False, stdin=None):
   171          """Running shell command
   172          Keyword arguments:
   173          cmd -- command to run
   174          cwd -- dir to run the cmd
   175          env -- environment variables
   176          ignore_errors -- don't raise exception if command fails
   177          stdin -- standard input for the command in bytes
   178          """
   179  
   180          cmd_env = {
   181              "SSH_AUTH_SOCK": self.conf.utils.ssh_sock,
   182              "PATH": os.environ['PATH'],
   183              **env
   184          }
   185  
   186          if cwd and not os.path.exists(cwd):
   187              raise FileNotFoundError(Format.alert("Directory {} does not exists".format(cwd)))
   188  
   189          if logging.DEBUG >= logger.level:
   190              logger.debug("Executing command\n"
   191                           "    cwd: {} \n"
   192                           "    env: {}\n"
   193                           "    cmd: {}".format(cwd, str(cmd_env) if cmd_env else "{}", cmd))
   194          else:
   195              logger.info("Executing command {}".format(cmd))
   196  
   197          stdout, stderr = [], []
   198          p = subprocess.Popen(
   199              cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd,
   200              stdin=subprocess.PIPE if stdin else None, shell=True, env=cmd_env
   201          )
   202          if stdin:
   203              p.stdin.write(stdin)
   204              p.stdin.close()
   205          stdoutStreamer = Thread(target = self.read_fd, args = (p, p.stdout, logger.debug, stdout))
   206          stderrStreamer = Thread(target = self.read_fd, args = (p, p.stderr, logger.error, stderr))
   207          stdoutStreamer.start()
   208          stderrStreamer.start()
   209          stdoutStreamer.join()
   210          stderrStreamer.join()
   211          # this is redundant, at this point threads were joined and they waited for the subprocess
   212          # to exit, however it should not hurt to explicitly wait for it again (no-op).
   213          p.wait()
   214          stdout, stderr = "".join(stdout), "".join(stderr)
   215  
   216          if p.returncode != 0:
   217              if not ignore_errors:
   218                  raise RuntimeError("Error executing command {}".format(cmd))
   219              else:
   220                  return stderr
   221          return stdout
   222  
   223      def read_fd(self, proc, fd, logger_func, output):
   224          """Read from fd, logging using logger_func
   225  
   226          Read from fd, until proc is finished. All contents will
   227          also be appended onto output."""
   228          while True:
   229              contents = fd.readline().decode()
   230              if contents == '' and proc.poll() is not None:
   231                  return
   232              if contents:
   233                  output.append(contents)
   234                  logger_func(contents.strip())
   235  
   236      @timeout(60)
   237      @step
   238      def setup_ssh(self):
   239          os.chmod(self.conf.utils.ssh_key, 0o400)
   240  
   241          # use a dedicated agent to minimize stateful components
   242          sock_fn = self.conf.utils.ssh_sock
   243          # be sure directory containing socket exists and socket doesn't exist
   244          if os.path.exists(sock_fn):
   245              try:
   246                  if os.path.isdir(sock_fn):
   247                      os.path.rmdir(sock_fn)  # rmdir only removes an empty dir
   248                  else:
   249                      os.remove(sock_fn)
   250              except FileNotFoundError:
   251                  pass
   252          try:
   253              os.mkdir(os.path.dirname(sock_fn), mode=0o700)
   254          except FileExistsError:
   255              if os.path.isdir(os.path.dirname(sock_fn)):
   256                  pass
   257              else:
   258                  raise
   259          # clean up old ssh agent process(es)
   260          try:
   261              self.runshellcommand("pkill -f 'ssh-agent -a {}'".format(sock_fn))
   262              logger.warning("Killed previous instance of ssh-agent")
   263          except:
   264              pass
   265          self.runshellcommand("ssh-agent -a {}".format(sock_fn))
   266          self.runshellcommand(
   267              "ssh-add " + self.conf.utils.ssh_key, env={"SSH_AUTH_SOCK": sock_fn})
   268  
   269      @timeout(30)
   270      @step
   271      def info(self):
   272          """Node info"""
   273          info_lines = "Env vars: {}\n".format(sorted(os.environ))
   274          info_lines += self.runshellcommand('ip a')
   275          info_lines += self.runshellcommand('ip r')
   276          info_lines += self.runshellcommand('cat /etc/resolv.conf')
   277  
   278          # TODO: the logic for retrieving external is platform depedant and should be
   279          # moved to the corresponding platform
   280          try:
   281              r = requests.get(
   282                  'http://169.254.169.254/2009-04-04/meta-data/public-ipv4', timeout=2)
   283              r.raise_for_status()
   284          except (requests.HTTPError, requests.Timeout) as err:
   285              logger.warning(
   286                  f'Meta Data service unavailable could not get external IP addr{err}')
   287          else:
   288              info_lines += 'External IP addr: {}'.format(r.text)
   289  
   290          return info_lines