github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/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)