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