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