github.com/ethanhsieh/snapd@v0.0.0-20210615102523-3db9b8e4edc5/spread-shellcheck (about)

     1  #!/usr/bin/env python3
     2  
     3  # Copyright (C) 2018 Canonical Ltd
     4  #
     5  # This program is free software: you can redistribute it and/or modify
     6  # it under the terms of the GNU General Public License version 3 as
     7  # published by the Free Software Foundation.
     8  #
     9  # This program is distributed in the hope that it will be useful,
    10  # but WITHOUT ANY WARRANTY; without even the implied warranty of
    11  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    12  # GNU General Public License for more details.
    13  #
    14  # You should have received a copy of the GNU General Public License
    15  # along with this program.  If not, see <http://www.gnu.org/licenses/>.
    16  
    17  import logging
    18  import os
    19  import subprocess
    20  import argparse
    21  import itertools
    22  from concurrent.futures import ThreadPoolExecutor
    23  from multiprocessing import cpu_count
    24  from typing import Dict
    25  
    26  import yaml
    27  
    28  
    29  # default shell for shellcheck
    30  SHELLCHECK_SHELL = os.getenv('SHELLCHECK_SHELL', 'bash')
    31  # set to non-empty to ignore all errors
    32  NO_FAIL = os.getenv('NO_FAIL')
    33  # set to non empty to enable 'set -x'
    34  D = os.getenv('D')
    35  # set to non-empty to enable verbose logging
    36  V = os.getenv('V')
    37  # set to a number to use these many threads
    38  N = int(os.getenv('N') or cpu_count())
    39  # file with list of files that can fail validation
    40  CAN_FAIL = os.getenv('CAN_FAIL')
    41  
    42  # names of sections
    43  SECTIONS = ['prepare', 'prepare-each', 'restore', 'restore-each',
    44              'debug', 'debug-each', 'execute', 'repack']
    45  
    46  
    47  def parse_arguments():
    48      parser = argparse.ArgumentParser(description='spread shellcheck helper')
    49      parser.add_argument('-s', '--shell', default='bash',
    50                          help='shell')
    51      parser.add_argument('-n', '--no-errors', action='store_true',
    52                          default=False, help='ignore all errors ')
    53      parser.add_argument('-v', '--verbose', action='store_true',
    54                          default=False, help='verbose logging')
    55      parser.add_argument('--can-fail', default=None,
    56                          help=('file with list of files that are can fail '
    57                                'validation'))
    58      parser.add_argument('-P', '--max-procs', default=N, type=int, metavar='N',
    59                          help='run these many shellchecks in parallel (default: %(default)s)')
    60      parser.add_argument('paths', nargs='+', help='paths to check')
    61      return parser.parse_args()
    62  
    63  
    64  class ShellcheckRunError(Exception):
    65      def __init__(self, stderr):
    66          super().__init__()
    67          self.stderr = stderr
    68  
    69  
    70  class ShellcheckError(Exception):
    71      def __init__(self, path):
    72          super().__init__()
    73          self.sectionerrors = {}
    74          self.path = path
    75  
    76      def addfailure(self, section, error):
    77          self.sectionerrors[section] = error
    78  
    79      def __len__(self):
    80          return len(self.sectionerrors)
    81  
    82  
    83  class ShellcheckFailures(Exception):
    84      def __init__(self, failures=None):
    85          super().__init__()
    86          self.failures = set()
    87          if failures:
    88              self.failures = set(failures)
    89  
    90      def merge(self, otherfailures):
    91          self.failures = self.failures.union(otherfailures.failures)
    92  
    93      def __len__(self):
    94          return len(self.failures)
    95  
    96      def intersection(self, other):
    97          return self.failures.intersection(other)
    98  
    99      def difference(self, other):
   100          return self.failures.difference(other)
   101  
   102      def __iter__(self):
   103          return iter(self.failures)
   104  
   105  
   106  def checksection(data, env: Dict[str, str]):
   107      # spread shell snippets are executed under 'set -e' shell, make sure
   108      # shellcheck knows about that
   109      script_data = []
   110      script_data.append('set -e')
   111  
   112      for key, value in env.items():
   113          value = str(value)
   114          # Unpack the special "$(HOST: ...) syntax and tell shellcheck not to
   115          # worry about the use of echo to print variable value.
   116          if value.startswith("$(HOST:") and value.endswith(")"):
   117              script_data.append("# shellcheck disable=SC2116")
   118              value = "$({})".format(value[len("$(HOST:"):-1])
   119          # XXX: poor man's shell key=value assignment with values in double
   120          # quotes so that one value can refer to another value.
   121          if '"' in value:
   122              value = value.replace('"', '\"')
   123          # converts
   124          # FOO: "$(HOST: echo $foo)"     -> FOO="$(echo $foo)"
   125          # FOO: "$(HOST: echo \"$foo\")" -> FOO="$(echo \"$foo\")"
   126          # FOO: "foo"                    -> FOO="foo"
   127          script_data.append("{}=\"{}\"".format(key, value))
   128          script_data.append("export {}".format(key))
   129      script_data.append(data)
   130      proc = subprocess.Popen("shellcheck -s {} -x -".format(SHELLCHECK_SHELL),
   131                              stdout=subprocess.PIPE,
   132                              stdin=subprocess.PIPE,
   133                              shell=True)
   134      stdout, _ = proc.communicate(input='\n'.join(script_data).encode('utf-8'), timeout=30)
   135      if proc.returncode != 0:
   136          raise ShellcheckRunError(stdout)
   137  
   138  
   139  def checkfile(path, executor):
   140      logging.debug("checking file %s", path)
   141      with open(path) as inf:
   142          data = yaml.load(inf, Loader=yaml.CSafeLoader)
   143  
   144      errors = ShellcheckError(path)
   145      # TODO: handle stacking of environment from other places that influence it:
   146      # spread.yaml -> global env + backend env + suite env -> task.yaml (task
   147      # env + variant env).
   148      env = {}
   149      for key, value in data.get("environment", {}).items():
   150          if "/" in key:
   151              # TODO: re-check with each variant's value set.
   152              key = key.split('/', 1)[0]
   153          env[key] = value
   154      for section in SECTIONS:
   155          if section not in data:
   156              continue
   157          try:
   158              logging.debug("%s: checking section %s", path, section)
   159              checksection(data[section], env)
   160          except ShellcheckRunError as serr:
   161              errors.addfailure(section, serr.stderr.decode('utf-8'))
   162  
   163      if path.endswith('spread.yaml') and 'suites' in data:
   164          # check suites
   165          suites_sections_and_futures = []
   166          for suite in data['suites'].keys():
   167              for section in SECTIONS:
   168                  if section not in data['suites'][suite]:
   169                      continue
   170                  logging.debug("%s (suite %s): checking section %s", path, suite, section)
   171                  future = executor.submit(checksection, data['suites'][suite][section], env)
   172                  suites_sections_and_futures.append((suite, section, future))
   173          for item in suites_sections_and_futures:
   174              suite, section, future = item
   175              try:
   176                  future.result()
   177              except ShellcheckRunError as serr:
   178                  errors.addfailure('suites/' + suite + '/' + section,
   179                                  serr.stderr.decode('utf-8'))
   180  
   181      if errors:
   182          raise errors
   183  
   184  
   185  def findfiles(locations):
   186      for loc in locations:
   187          if os.path.isdir(loc):
   188              for root, _, files in os.walk(loc, topdown=True):
   189                  for name in files:
   190                      if name in ['spread.yaml', 'task.yaml']:
   191                          yield os.path.join(root, name)
   192          else:
   193              yield loc
   194  
   195  
   196  def check1path(path, executor):
   197      try:
   198          checkfile(path, executor)
   199      except ShellcheckError as err:
   200          return err
   201      return None
   202  
   203  
   204  def checkpaths(locs, executor):
   205      # setup iterator
   206      locations = findfiles(locs)
   207      failed = []
   208      for serr in executor.map(check1path, locations, itertools.repeat(executor)):
   209          if serr is None:
   210              continue
   211          logging.error(('shellcheck failed for file %s in sections: '
   212                         '%s; error log follows'),
   213                        serr.path, ', '.join(serr.sectionerrors.keys()))
   214          for section, error in serr.sectionerrors.items():
   215              logging.error("%s: section '%s':\n%s", serr.path, section, error)
   216          failed.append(serr.path)
   217  
   218      if failed:
   219          raise ShellcheckFailures(failures=failed)
   220  
   221  
   222  def loadfilelist(flistpath):
   223      flist = set()
   224      with open(flistpath) as inf:
   225          for line in inf:
   226              if not line.startswith('#'):
   227                  flist.add(line.strip())
   228      return flist
   229  
   230  
   231  def main(opts):
   232      paths = opts.paths or ['.']
   233      failures = ShellcheckFailures()
   234      with ThreadPoolExecutor(max_workers=opts.max_procs) as executor:
   235          try:
   236              checkpaths(paths, executor)
   237          except ShellcheckFailures as sf:
   238              failures.merge(sf)
   239  
   240      if failures:
   241          if opts.can_fail:
   242              can_fail = loadfilelist(opts.can_fail)
   243  
   244              unexpected = failures.difference(can_fail)
   245              if unexpected:
   246                  logging.error(('validation failed for the following '
   247                                 'non-whitelisted files:\n%s'),
   248                                '\n'.join([' - ' + f for f in
   249                                           sorted(unexpected)]))
   250                  raise SystemExit(1)
   251  
   252              did_not_fail = can_fail - failures.intersection(can_fail)
   253              if did_not_fail:
   254                  logging.error(('the following files are whitelisted '
   255                                 'but validated successfully:\n%s'),
   256                                '\n'.join([' - ' + f for f in
   257                                           sorted(did_not_fail)]))
   258                  raise SystemExit(1)
   259  
   260              # no unexpected failures
   261              return
   262  
   263          logging.error('validation failed for the following files:\n%s',
   264                        '\n'.join([' - ' + f for f in sorted(failures)]))
   265  
   266          if NO_FAIL or opts.no_errors:
   267              logging.warning("ignoring errors")
   268          else:
   269              raise SystemExit(1)
   270  
   271  
   272  if __name__ == '__main__':
   273      opts = parse_arguments()
   274      if opts.verbose or D or V:
   275          lvl = logging.DEBUG
   276      else:
   277          lvl = logging.INFO
   278      logging.basicConfig(level=lvl)
   279  
   280      if CAN_FAIL:
   281          opts.can_fail = CAN_FAIL
   282  
   283      if NO_FAIL:
   284          opts.no_errors = True
   285  
   286      if opts.max_procs == 1:
   287          # TODO: temporary workaround for a deadlock when running with a single
   288          # worker
   289          opts.max_procs += 1
   290          logging.warning('workers count bumped to 2 to workaround a deadlock')
   291  
   292      main(opts)