Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 #!/usr/bin/env python | |
| 2 # Copyright 2015 The Chromium Authors. All rights reserved. | |
| 3 # Use of this source code is governed by a BSD-style license that can be | |
| 4 # found in the LICENSE file. | |
| 5 | |
| 6 import argparse | |
| 7 import hashlib | |
| 8 import httplib | |
| 9 import json | |
| 10 import logging | |
| 11 import os | |
| 12 import platform | |
| 13 import re | |
| 14 import socket | |
| 15 import ssl | |
| 16 import stat | |
| 17 import subprocess | |
| 18 import sys | |
| 19 import tempfile | |
| 20 import time | |
| 21 import urllib | |
| 22 import urllib2 | |
| 23 | |
| 24 | |
| 25 # The path to the "infra/bootstrap/" directory. | |
| 26 BOOTSTRAP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| 27 # The path to the "infra/" directory. | |
| 28 ROOT = os.path.dirname(BOOTSTRAP_DIR) | |
| 29 # The path where CIPD install lists are stored. | |
| 30 CIPD_LIST_DIR = os.path.join(BOOTSTRAP_DIR, 'cipd') | |
| 31 # Default binary install root. | |
| 32 DEFAULT_INSTALL_ROOT = os.path.join(ROOT, 'bin') | |
| 33 | |
| 34 # Path to CA certs bundle file to use by default. | |
| 35 DEFAULT_CERT_FILE = os.path.join(ROOT, 'data', 'cacert.pem') | |
| 36 | |
| 37 # The default URL of the CIPD backend service. | |
| 38 CIPD_BACKEND_URL = 'https://chrome-infra-packages.appspot.com' | |
| 39 | |
| 40 # Regular expression that matches CIPD raw instance IDs. | |
| 41 RE_CIPD_INSTANCE_ID = re.compile(r'^[0-9a-f]{40}$') | |
| 42 | |
| 43 # Map of CIPD configuration based on the current architecture/platform. If a | |
| 44 # platform is not listed here, the bootstrap will be a no-op. | |
| 45 # | |
| 46 # This is keyed on the platform's (system, machine). | |
| 47 # | |
| 48 # It is ideal to use a raw `instance_id` as the version to avoid an unnecessary | |
| 49 # CIPD server round-trip lookup. This can be obtained for a given package via: | |
| 50 # $ cipd resolve \ | |
|
Vadim Sh.
2015/09/24 18:09:57
nit: "cipd resolve infra/tools/cipd/ -version=..."
dnj
2015/09/24 18:14:35
Done.
| |
| 51 # <package> \ | |
| 52 # -version=git_revision:ed808139130f0ea8fd52fc64fd9ec47d150a2e49" | |
| 53 ARCH_CONFIG_MAP = { | |
| 54 ('Linux', 'x86_64'): { | |
| 55 'cipd_package': 'infra/tools/cipd/linux-amd64', | |
| 56 'cipd_package_version': '99439270562c887c887b7538ed634ed3a3c0dc83', | |
| 57 'cipd_install_lists': [ | |
| 58 'cipd_linux_amd64.txt', | |
| 59 ], | |
| 60 }, | |
| 61 } | |
| 62 | |
| 63 | |
| 64 class CipdError(Exception): | |
| 65 """Raised by install_cipd_client on fatal error.""" | |
| 66 | |
| 67 | |
| 68 def get_platform_config(): | |
| 69 key = (platform.system(), platform.machine()) | |
| 70 return key, ARCH_CONFIG_MAP.get(key) | |
| 71 | |
| 72 | |
| 73 def dump_json(obj): | |
| 74 """Pretty-formats object to JSON.""" | |
| 75 return json.dumps(obj, indent=2, sort_keys=True, separators=(',',':')) | |
| 76 | |
| 77 | |
| 78 def ensure_directory(path): | |
| 79 # Ensure the parent directory exists. | |
| 80 if os.path.isdir(path): | |
| 81 return | |
| 82 if os.path.exists(path): | |
| 83 raise ValueError("Target file's directory [%s] exists, but is not a " | |
| 84 "directory." % (path,)) | |
| 85 logging.debug('Creating directory: [%s]', path) | |
| 86 os.makedirs(path) | |
| 87 | |
| 88 | |
| 89 def write_binary_file(path, data): | |
| 90 """Writes a binary file to the disk.""" | |
| 91 ensure_directory(os.path.dirname(path)) | |
| 92 with open(path, 'wb') as fd: | |
| 93 fd.write(data) | |
| 94 | |
| 95 | |
| 96 def write_tag_file(path, obj): | |
| 97 with open(path, 'w') as fd: | |
| 98 json.dump(obj, fd, sort_keys=True, indent=2) | |
| 99 | |
| 100 | |
| 101 def read_tag_file(path): | |
| 102 try: | |
| 103 with open(path, 'r') as fd: | |
| 104 return json.load(fd) | |
| 105 except (IOError, ValueError): | |
| 106 return None | |
| 107 | |
| 108 | |
| 109 def is_cipd_instance_id(value): | |
| 110 return RE_CIPD_INSTANCE_ID.match(value) is not None | |
| 111 | |
| 112 | |
| 113 def resolve_cipd_instance_id(cipd_backend, package, version): | |
| 114 if is_cipd_instance_id(version): | |
| 115 return version | |
| 116 | |
| 117 resp = cipd_backend.call_api( | |
| 118 'repo/v1/instance/resolve', | |
| 119 package_name=package, | |
| 120 version=version) | |
| 121 return resp['instance_id'] | |
| 122 | |
| 123 | |
| 124 def install_cipd_client(cipd_backend, config, root): | |
| 125 package = config['cipd_package'] | |
| 126 instance_id = resolve_cipd_instance_id( | |
| 127 cipd_backend, | |
| 128 package, | |
| 129 config.get('cipd_package_version', 'latest')) | |
| 130 logging.info('Installing CIPD client [%s] ID [%s]', package, instance_id) | |
| 131 | |
| 132 # Is this the version that's already installed? | |
| 133 cipd_client = CipdClient(cipd_backend, os.path.join(root, 'cipd')) | |
| 134 current = cipd_client.read_tag() | |
| 135 if current == (package, instance_id) and os.path.isfile(cipd_client.path): | |
| 136 logging.info('CIPD client already installed.') | |
| 137 return cipd_client | |
| 138 | |
| 139 # Get the client binary URL. | |
| 140 client_info = cipd_backend.call_api( | |
| 141 'repo/v1/client', | |
| 142 package_name=package, | |
| 143 instance_id=instance_id) | |
| 144 logging.info('CIPD client binary info:\n%s', dump_json(client_info)) | |
| 145 | |
| 146 status, raw_client_data = fetch_url(client_info['client_binary']['fetch_url']) | |
| 147 if status != 200: | |
| 148 logging.error('Failed to fetch CIPD client binary (HTTP status %d)', status) | |
| 149 return None | |
| 150 | |
| 151 digest = hashlib.sha1(raw_client_data).hexdigest() | |
| 152 if digest != client_info['client_binary']['sha1']: | |
| 153 logging.error('CIPD client hash mismatch (%s != %s)', digest, | |
| 154 client_info['client_binary']['sha1']) | |
| 155 return None | |
| 156 | |
| 157 write_binary_file(cipd_client.path, raw_client_data) | |
| 158 os.chmod(cipd_client.path, 0755) | |
| 159 cipd_client.write_tag(package, instance_id) | |
| 160 return cipd_client | |
| 161 | |
| 162 | |
| 163 def execute(*cmd): | |
| 164 if not logging.getLogger().isEnabledFor(logging.DEBUG): | |
| 165 code = subprocess.call(cmd) | |
| 166 else: | |
| 167 # Execute the process, passing STDOUT/STDERR through our logger. | |
| 168 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, | |
| 169 stderr=subprocess.STDOUT) | |
| 170 for line in proc.stdout: | |
| 171 logging.debug('%s: %s', cmd[0], line.rstrip()) | |
| 172 code = proc.wait() | |
| 173 if code: | |
| 174 logging.error('Process failed with exit code: %d', code) | |
| 175 return code | |
| 176 | |
| 177 | |
| 178 class CipdBackend(object): | |
| 179 def __init__(self, url): | |
| 180 self.url = url | |
| 181 | |
| 182 def call_api(self, endpoint, **query): | |
| 183 """Sends GET request to CIPD backend, parses JSON response.""" | |
| 184 url = '%s/_ah/api/%s' % (self.url, endpoint) | |
| 185 if query: | |
| 186 url += '?' + urllib.urlencode(sorted(query.iteritems()), True) | |
| 187 status, body = fetch_url(url) | |
| 188 if status != 200: | |
| 189 raise CipdError('Server replied with HTTP %d' % status) | |
| 190 try: | |
| 191 body = json.loads(body) | |
| 192 except ValueError: | |
| 193 raise CipdError('Server returned invalid JSON') | |
| 194 status = body.get('status') | |
| 195 if status != 'SUCCESS': | |
| 196 m = body.get('error_message') or '<no error message>' | |
| 197 raise CipdError('Server replied with error %s: %s' % (status, m)) | |
| 198 return body | |
| 199 | |
| 200 | |
| 201 class CipdClient(object): | |
| 202 | |
| 203 # Filename for the CIPD package/instance_id tag. | |
| 204 _TAG_NAME = '.cipd_client_version' | |
| 205 | |
| 206 def __init__(self, cipd_backend, path): | |
| 207 self.cipd_backend = cipd_backend | |
| 208 self.path = path | |
| 209 | |
| 210 def exists(self): | |
| 211 return os.path.isfile(self.path) | |
| 212 | |
| 213 def ensure(self, list_path, root): | |
| 214 assert os.path.isfile(list_path) | |
| 215 assert os.path.isdir(root) | |
| 216 self.call( | |
| 217 'ensure', | |
| 218 '-list', list_path, | |
| 219 '-root', root, | |
| 220 '-service-url', self.cipd_backend.url) | |
| 221 | |
| 222 def call(self, *args): | |
| 223 if execute(self.path, *args): | |
| 224 raise CipdError('Failed to execute CIPD client: %s', ' '.join(args)) | |
| 225 | |
| 226 def write_tag(self, package, instance_id): | |
| 227 write_tag_file(self._cipd_tag_path, { | |
| 228 'package': package, | |
| 229 'instance_id': instance_id, | |
| 230 }) | |
| 231 | |
| 232 def read_tag(self): | |
| 233 tag = read_tag_file(self._cipd_tag_path) | |
| 234 if tag is None: | |
| 235 return None, None | |
| 236 return tag.get('package'), tag.get('instance_id') | |
| 237 | |
| 238 @property | |
| 239 def _cipd_tag_path(self): | |
| 240 return os.path.join(os.path.dirname(self.path), self._TAG_NAME) | |
| 241 | |
| 242 | |
| 243 def fetch_url(url, headers=None): | |
| 244 """Sends GET request (with retries). | |
| 245 Args: | |
| 246 url: URL to fetch. | |
| 247 headers: dict with request headers. | |
| 248 Returns: | |
| 249 (200, reply body) on success. | |
| 250 (HTTP code, None) on HTTP 401, 403, or 404 reply. | |
| 251 Raises: | |
| 252 Whatever urllib2 raises. | |
| 253 """ | |
| 254 req = urllib2.Request(url) | |
| 255 req.add_header('User-Agent', 'infra-install-cipd-packages') | |
| 256 for k, v in (headers or {}).iteritems(): | |
| 257 req.add_header(str(k), str(v)) | |
| 258 i = 0 | |
| 259 while True: | |
| 260 i += 1 | |
| 261 try: | |
| 262 logging.debug('GET %s', url) | |
| 263 return 200, urllib2.urlopen(req, timeout=60).read() | |
| 264 except Exception as e: | |
| 265 if isinstance(e, urllib2.HTTPError): | |
| 266 logging.error('Failed to fetch %s, server returned HTTP %d', url, | |
| 267 e.code) | |
| 268 if e.code in (401, 403, 404): | |
| 269 return e.code, None | |
| 270 else: | |
| 271 logging.exception('Failed to fetch %s', url) | |
| 272 if i == 20: | |
| 273 raise | |
| 274 logging.info('Retrying in %d sec.', i) | |
| 275 time.sleep(i) | |
| 276 | |
| 277 | |
| 278 def setup_urllib2_ssl(cacert): | |
| 279 """Configures urllib2 to validate SSL certs. | |
| 280 See http://stackoverflow.com/a/14320202/3817699. | |
| 281 """ | |
| 282 cacert = os.path.abspath(cacert) | |
| 283 assert os.path.isfile(cacert) | |
| 284 | |
| 285 class ValidHTTPSConnection(httplib.HTTPConnection): | |
| 286 default_port = httplib.HTTPS_PORT | |
| 287 def __init__(self, *args, **kwargs): | |
| 288 httplib.HTTPConnection.__init__(self, *args, **kwargs) | |
| 289 def connect(self): | |
| 290 sock = socket.create_connection( | |
| 291 (self.host, self.port), self.timeout, self.source_address) | |
| 292 if self._tunnel_host: | |
| 293 self.sock = sock | |
| 294 self._tunnel() | |
| 295 self.sock = ssl.wrap_socket( | |
| 296 sock, ca_certs=cacert, cert_reqs=ssl.CERT_REQUIRED) | |
| 297 class ValidHTTPSHandler(urllib2.HTTPSHandler): | |
| 298 def https_open(self, req): | |
| 299 return self.do_open(ValidHTTPSConnection, req) | |
| 300 urllib2.install_opener(urllib2.build_opener(ValidHTTPSHandler)) | |
| 301 | |
| 302 | |
| 303 def main(argv): | |
| 304 parser = argparse.ArgumentParser('Installs CIPD bootstrap packages.') | |
| 305 parser.add_argument('-v', '--verbose', action='count', default=0, | |
| 306 help='Increase logging verbosity. Can be specified multiple times.') | |
| 307 parser.add_argument('--cipd-backend-url', metavar='URL', | |
| 308 default=CIPD_BACKEND_URL, | |
| 309 help='Specify the CIPD backend URL (default is %(default)s)') | |
| 310 parser.add_argument('-d', '--cipd-root-dir', metavar='PATH', | |
| 311 default=DEFAULT_INSTALL_ROOT, | |
| 312 help='Specify the root CIPD package installation directory.') | |
| 313 parser.add_argument('--cacert', metavar='PATH', default=DEFAULT_CERT_FILE, | |
| 314 help='Path to cacert.pem file with CA root certificates bundle (default ' | |
| 315 'is %(default)s)') | |
| 316 | |
| 317 opts = parser.parse_args(argv) | |
| 318 | |
| 319 # Setup logging verbosity. | |
| 320 if opts.verbose == 0: | |
| 321 level = logging.WARNING | |
| 322 elif opts.verbose == 1: | |
| 323 level = logging.INFO | |
| 324 else: | |
| 325 level = logging.DEBUG | |
| 326 logging.getLogger().setLevel(level) | |
| 327 | |
| 328 # Configure `urllib2` to validate SSL certificates. | |
| 329 logging.debug('CA certs bundle: %s', opts.cacert) | |
| 330 setup_urllib2_ssl(opts.cacert) | |
| 331 | |
| 332 # Make sure our root directory exists. | |
| 333 root = os.path.abspath(opts.cipd_root_dir) | |
| 334 ensure_directory(root) | |
| 335 | |
| 336 platform_key, config = get_platform_config() | |
| 337 if not config: | |
| 338 logging.info('No bootstrap configuration for platform [%s].', platform_key) | |
| 339 return 0 | |
| 340 | |
| 341 cipd_backend = CipdBackend(opts.cipd_backend_url) | |
| 342 cipd = install_cipd_client(cipd_backend, config, root) | |
| 343 if not cipd: | |
| 344 logging.error('Failed to install CIPD client.') | |
| 345 return 1 | |
| 346 assert cipd.exists() | |
| 347 | |
| 348 for l in config.get('cipd_install_lists', ()): | |
| 349 cipd.ensure(os.path.join(CIPD_LIST_DIR, l), root) | |
| 350 return 0 | |
| 351 | |
| 352 | |
| 353 if __name__ == '__main__': | |
| 354 logging.basicConfig() | |
| 355 logging.getLogger().setLevel(logging.INFO) | |
| 356 sys.exit(main(sys.argv[1:])) | |
| OLD | NEW |