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)