2 # Copyright 2019 The Pigweed Authors
4 # Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 # use this file except in compliance with the License. You may obtain a copy of
8 # https://www.apache.org/licenses/LICENSE-2.0
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations under
15 """Checks if the environment is set up correctly for Pigweed."""
18 from concurrent import futures
27 from typing import Callable, Iterable, List, Set
32 def call_stdout(*args, **kwargs):
33 kwargs.update(stdout=subprocess.PIPE)
34 proc = subprocess.run(*args, **kwargs)
35 return proc.stdout.decode('utf-8')
38 class _Fatal(Exception):
43 def __init__(self, *, log: logging.Logger = None, strict: bool = False):
45 self.log = log or logging.getLogger(__name__)
46 self.failures: Set[str] = set()
48 def run(self, checks: Iterable[Callable]):
49 with futures.ThreadPoolExecutor() as executor:
51 executor.submit(self._run_check, c, executor) for c in checks
54 def _run_check(self, check, executor):
55 ctx = DoctorContext(self, check.__name__, executor)
57 self.log.debug('Running check %s', ctx.check)
62 except: # pylint: disable=bare-except
63 self.failures.add(ctx.check)
64 self.log.exception('%s failed with an unexpected exception',
67 self.log.debug('Completed check %s', ctx.check)
71 """The context object provided to each context function."""
72 def __init__(self, doctor: Doctor, check: str, executor: futures.Executor):
75 self._executor = executor
76 self._futures: List[futures.Future] = []
78 def submit(self, function, *args, **kwargs):
79 """Starts running the provided function in parallel."""
81 self._executor.submit(self._run_job, function, *args, **kwargs))
84 """Waits for all parallel tasks started with submit() to complete."""
85 futures.wait(self._futures)
88 def _run_job(self, function, *args, **kwargs):
90 function(*args, **kwargs)
93 except: # pylint: disable=bare-except
94 self._doctor.failures.add(self.check)
95 self._doctor.log.exception(
96 '%s failed with an unexpected exception', self.check)
98 def fatal(self, fmt, *args, **kwargs):
99 """Same as error() but terminates the check early."""
100 self.error(fmt, *args, **kwargs)
103 def error(self, fmt, *args, **kwargs):
104 self._doctor.log.error(fmt, *args, **kwargs)
105 self._doctor.failures.add(self.check)
107 def warning(self, fmt, *args, **kwargs):
108 if self._doctor.strict:
109 self.error(fmt, *args, **kwargs)
111 self._doctor.log.warning(fmt, *args, **kwargs)
113 def info(self, fmt, *args, **kwargs):
114 self._doctor.log.info(fmt, *args, **kwargs)
116 def debug(self, fmt, *args, **kwargs):
117 self._doctor.log.debug(fmt, *args, **kwargs)
120 def register_into(dest):
128 CHECKS: List[Callable] = []
131 @register_into(CHECKS)
132 def pw_plugins(ctx: DoctorContext):
133 if pw_cli.plugins.errors():
134 ctx.error('Not all pw plugins loaded successfully')
137 @register_into(CHECKS)
138 def env_os(ctx: DoctorContext):
139 """Check that the environment matches this machine."""
140 if '_PW_ACTUAL_ENVIRONMENT_ROOT' not in os.environ:
142 env_root = pathlib.Path(os.environ['_PW_ACTUAL_ENVIRONMENT_ROOT'])
143 config = env_root / 'config.json'
144 if not config.is_file():
147 with open(config, 'r') as ins:
148 data = json.load(ins)
149 if data['os'] != os.name:
150 ctx.error('Current OS (%s) does not match bootstrapped OS (%s)',
153 # Skipping sysname and nodename in os.uname(). nodename could change
154 # based on the current network. sysname won't change, but is
155 # redundant because it's contained in release or version, and
156 # skipping it here simplifies logic.
157 uname = ' '.join(getattr(os, 'uname', lambda: ())()[2:])
158 if data['uname'] != uname:
160 'Current uname (%s) does not match Bootstrap uname (%s), '
161 'you may need to rerun bootstrap on this system', uname,
165 @register_into(CHECKS)
166 def pw_root(ctx: DoctorContext):
167 """Check that environment variable PW_ROOT is set and makes sense."""
169 root = pathlib.Path(os.environ['PW_ROOT']).resolve()
171 ctx.fatal('PW_ROOT not set')
173 git_root = pathlib.Path(
174 call_stdout(['git', 'rev-parse', '--show-toplevel'], cwd=root).strip())
175 git_root = git_root.resolve()
177 ctx.error('PW_ROOT (%s) != `git rev-parse --show-toplevel` (%s)', root,
181 @register_into(CHECKS)
182 def git_hook(ctx: DoctorContext):
183 """Check that presubmit git hook is installed."""
184 if not os.environ.get('PW_ENABLE_PRESUBMIT_HOOK_WARNING'):
188 root = pathlib.Path(os.environ['PW_ROOT'])
190 return # This case is handled elsewhere.
192 hook = root / '.git' / 'hooks' / 'pre-push'
193 if not os.path.isfile(hook):
194 ctx.info('Presubmit hook not installed, please run '
195 "'pw presubmit --install' before pushing changes.")
198 @register_into(CHECKS)
199 def python_version(ctx: DoctorContext):
200 """Check the Python version is correct."""
201 actual = sys.version_info
203 if (actual[0:2] < expected or actual[0] != expected[0]
204 or actual[0:2] > expected):
205 # If we get the wrong version but it still came from CIPD print a
206 # warning but give it a pass.
207 if 'chromium' in sys.version:
208 ctx.warning('Python %d.%d.x expected, got Python %d.%d.%d',
209 *expected, *actual[0:3])
211 ctx.error('Python %d.%d.x required, got Python %d.%d.%d',
212 *expected, *actual[0:3])
215 @register_into(CHECKS)
216 def virtualenv(ctx: DoctorContext):
217 """Check that we're in the correct virtualenv."""
219 venv_path = pathlib.Path(os.environ['VIRTUAL_ENV']).resolve()
221 ctx.error('VIRTUAL_ENV not set')
224 # When running in LUCI we might not have gone through the normal environment
225 # setup process, so we need to skip the rest of this step.
226 if 'LUCI_CONTEXT' in os.environ:
230 if '_PW_ACTUAL_ENVIRONMENT_ROOT' in os.environ:
231 var = '_PW_ACTUAL_ENVIRONMENT_ROOT'
232 root = pathlib.Path(os.environ[var]).resolve()
234 if root not in venv_path.parents:
235 ctx.error('VIRTUAL_ENV (%s) not inside %s (%s)', venv_path, var, root)
236 ctx.error('\n'.join(os.environ.keys()))
239 @register_into(CHECKS)
240 def cipd(ctx: DoctorContext):
241 """Check cipd is set up correctly and in use."""
242 if os.environ.get('PW_DOCTOR_SKIP_CIPD_CHECKS'):
245 cipd_path = 'pigweed'
247 cipd_exe = shutil.which('cipd')
249 ctx.fatal('cipd not in PATH (%s)', os.environ['PATH'])
251 temp = tempfile.NamedTemporaryFile(prefix='cipd', delete=False)
252 subprocess.run(['cipd', 'acl-check', '-json-output', temp.name, cipd_path],
253 stdout=subprocess.PIPE)
254 if not json.load(temp)['result']:
256 "can't access %s CIPD directory, have you run "
257 "'cipd auth-login'?", cipd_path)
259 commands_expected_from_cipd = [
266 # TODO(mohrr) get these tools in CIPD for Windows.
267 if os.name == 'posix':
268 commands_expected_from_cipd += [
274 for command in commands_expected_from_cipd:
275 path = shutil.which(command)
277 ctx.error('could not find %s in PATH (%s)', command,
279 elif 'cipd' not in path:
280 ctx.warning('not using %s from cipd, got %s (path is %s)', command,
281 path, os.environ['PATH'])
284 @register_into(CHECKS)
285 def cipd_versions(ctx: DoctorContext):
286 """Check cipd tool versions are current."""
288 if os.environ.get('PW_DOCTOR_SKIP_CIPD_CHECKS'):
292 root = pathlib.Path(os.environ['PW_ROOT']).resolve()
294 return # This case is handled elsewhere.
296 if 'PW_PIGWEED_CIPD_INSTALL_DIR' not in os.environ:
297 ctx.error('PW_PIGWEED_CIPD_INSTALL_DIR not set')
298 cipd_dir = pathlib.Path(os.environ['PW_PIGWEED_CIPD_INSTALL_DIR'])
300 versions_path = cipd_dir / '.versions'
301 # Deliberately not checking luci.json--it's not required to be up-to-date.
302 json_path = root.joinpath('pw_env_setup', 'py', 'pw_env_setup',
303 'cipd_setup', 'pigweed.json')
305 def check_cipd(package):
306 ctx.debug('checking version of %s', package['path'])
308 part for part in package['path'].split('/') if '{' not in part
310 path = versions_path.joinpath(f'{name}.cipd_version')
311 if not path.is_file():
312 ctx.debug('no version file')
315 with path.open() as ins:
316 installed = json.load(ins)
321 installed['package_name'],
323 installed['instance_id'],
325 ctx.debug('%s', ' '.join(describe))
326 output_raw = subprocess.check_output(describe).decode()
327 ctx.debug('output: %r', output_raw)
328 output = output_raw.split()
330 for tag in package['tags']:
331 if tag not in output:
333 'CIPD package %s is out of date, please rerun bootstrap',
334 installed['package_name'])
336 for package in json.loads(json_path.read_text()):
337 ctx.submit(check_cipd, package)
340 def run_doctor(strict=False, checks=None):
341 """Run all the Check subclasses defined in this file."""
344 checks = tuple(CHECKS)
346 doctor = Doctor(strict=strict)
347 doctor.log.debug('Doctor running %d checks...', len(checks))
352 doctor.log.info('Failed checks: %s', ', '.join(doctor.failures))
354 doctor.log.info('Environment passes all checks!')
355 return len(doctor.failures)
359 """Check that the environment is set up correctly for Pigweed."""
360 parser = argparse.ArgumentParser(description=__doc__)
364 help='Run additional checks.',
367 return run_doctor(**vars(parser.parse_args()))
370 if __name__ == '__main__':
371 # By default, display log messages like a simple print statement.
372 logging.basicConfig(format='%(message)s', level=logging.INFO)