Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 # Copyright 2015 The Chromium Authors. All rights reserved. | |
|
Vadim Sh.
2015/06/29 21:00:46
this script is very GCE-specific. Please remove al
seanmccullough
2015/06/30 17:39:39
Done.
| |
| 2 # Use of this source code is governed by a BSD-style license that can be | |
| 3 # found in the LICENSE file. | |
| 4 | |
| 5 import argparse | |
| 6 import errno | |
| 7 import hashlib | |
| 8 import httplib | |
| 9 import json | |
| 10 import logging | |
| 11 import logging.handlers | |
| 12 import os | |
| 13 import socket | |
| 14 import ssl | |
| 15 import subprocess | |
| 16 import sys | |
| 17 import tempfile | |
| 18 import time | |
| 19 import urllib | |
| 20 import urllib2 | |
| 21 | |
| 22 from recipe_engine import recipe_api | |
| 23 | |
| 24 # Global logger to use. | |
| 25 LOG = None | |
| 26 # Default package repository URL. | |
| 27 CIPD_BACKEND_URL = 'https://chrome-infra-packages.appspot.com' | |
| 28 # Path to CA certs bundle file to use by default. | |
| 29 DEFAULT_CERT_FILE = os.path.join( | |
| 30 os.path.abspath(os.path.dirname(__file__)), 'cacert.pem') | |
| 31 # URL of the GCE metadata server. | |
| 32 METADATA_SERVER = 'http://169.254.169.254/computeMetadata/v1' | |
| 33 # Name of the attribute with definition of what to install. | |
| 34 METADATA_ATTRIBUTE = 'cipd_deployments' | |
| 35 | |
| 36 | |
| 37 class CipdBootstrapError(Exception): | |
| 38 """Raised by install_cipd_client on fatal error.""" | |
| 39 | |
| 40 | |
| 41 class Environment(object): | |
| 42 """Environment wraps global properties passed to CipdDeployment objects. | |
| 43 | |
| 44 Args: | |
| 45 cipd: path to CIPD client executable. | |
| 46 service_account_json: optional path to JSON file with service account creds. | |
| 47 """ | |
| 48 | |
| 49 def __init__(self, root, cipd, service_account_json): | |
| 50 self.root = root | |
| 51 self.cipd = cipd | |
| 52 self.service_account_json = service_account_json | |
| 53 | |
| 54 | |
| 55 class CipdDeployment(object): | |
| 56 """Installs given CIPD packages into a given directory. | |
| 57 | |
| 58 Args: | |
| 59 site_root: path to the directory to install packages into. | |
| 60 packages: dict {package name -> version} of all packages to install. | |
| 61 env: instance of Environment object. | |
| 62 """ | |
| 63 | |
| 64 def __init__(self, site_root, packages, owner, group, env): | |
| 65 self.site_root = site_root | |
| 66 self.packages = packages | |
| 67 self.owner = owner | |
| 68 self.group = group | |
| 69 self.env = env | |
| 70 | |
| 71 @staticmethod | |
| 72 def create(spec, env): | |
| 73 path = spec.get('path') | |
| 74 if not isinstance(path, basestring) or not path.startswith('/'): | |
| 75 raise ValueError('Not an absolute path: %s' % (path,)) | |
| 76 packages = spec.get('packages') or {} | |
| 77 if not isinstance(packages, dict): | |
| 78 raise TypeError('"packages" must be dict: %r' % (packages,)) | |
| 79 for k, v in packages.iteritems(): | |
| 80 if not isinstance(k, basestring): | |
| 81 raise TypeError('Package name must be string: %r' % (k,)) | |
| 82 if not isinstance(v, basestring): | |
| 83 raise TypeError('Package version must be string: %r' % (v,)) | |
| 84 if not packages: | |
| 85 raise ValueError('At least one package must be specified for %s' % path) | |
| 86 owner = spec.get('owner') | |
| 87 if owner and not isinstance(owner, basestring): | |
| 88 raise TypeError('"owner" must be string: %r' % (owner,)) | |
| 89 group = spec.get('group') or owner | |
| 90 if group and not isinstance(group, basestring): | |
| 91 raise TypeError('"group" must be string: %r' % (group,)) | |
| 92 if group and not owner: | |
| 93 raise ValueError('If "group" is specified, "owner" must be specified too') | |
| 94 return CipdDeployment( | |
| 95 reroot(env.root, str(path)), packages, owner, group, env) | |
| 96 | |
| 97 def apply(self): | |
| 98 print('About to install to %s:', self.site_root) | |
| 99 for k, v in sorted(self.packages.items()): | |
| 100 print(' %s => %s', k, v) | |
| 101 package_list = '\n'.join( | |
| 102 '%s %s' % (k, v) for k, v in sorted(self.packages.iteritems())) | |
| 103 package_list_path = os.path.join(self.site_root, '.cipd', 'ensure.txt') | |
| 104 ensure_directory(os.path.dirname(package_list_path)) | |
| 105 with open(package_list_path, 'w') as f: | |
| 106 f.write(package_list) | |
| 107 cmd = [ | |
| 108 self.env.cipd, 'ensure', | |
| 109 '-list', package_list_path, | |
| 110 '-root', self.site_root, | |
| 111 '-service-url', CIPD_BACKEND_URL, | |
| 112 ] | |
| 113 if self.env.service_account_json: | |
| 114 cmd.extend(['-service-account-json', self.env.service_account_json]) | |
| 115 exit_code = execute(cmd) | |
| 116 if exit_code: | |
| 117 return exit_code | |
| 118 if self.owner and self.group: | |
| 119 return chown(self.site_root, self.owner, self.group) | |
| 120 return 0 | |
| 121 | |
| 122 | |
| 123 def run_update(source, root, service_account_json): | |
| 124 """Installs all packages specified in the instance metadata. | |
| 125 | |
| 126 Args: | |
| 127 source: optional path to JSON file to read instead of GCE metadata. | |
| 128 root: base directory for all paths specified in the JSON spec. | |
| 129 service_account_json: optional path to JSON file with service account creds. | |
| 130 | |
| 131 Returns: | |
| 132 Process exit code. | |
| 133 """ | |
| 134 # Grab a list of packages to install from GCE metadata attribute. | |
| 135 if source: | |
| 136 with open(source, 'r') as f: | |
| 137 attr = f.read() | |
| 138 else: | |
| 139 attr = get_gce_metadata(METADATA_ATTRIBUTE) | |
| 140 if not attr: | |
| 141 print('Nothing to install, no "%s" metadata is set', METADATA_ATTRIBUTE) | |
| 142 return 0 | |
| 143 spec = json.loads(attr) | |
| 144 print( | |
| 145 'Value of "%s" metadata attribute:\n%s', | |
| 146 METADATA_ATTRIBUTE, dump_json(spec)) | |
| 147 if spec.get('skip'): | |
| 148 print('skip == True, exiting') | |
| 149 return 0 | |
| 150 | |
| 151 # Bootstrap CIPD client. | |
| 152 client_spec = spec.get('cipd_client') | |
| 153 if not isinstance(client_spec, dict): | |
| 154 raise ValueError('Missing cipd_client section') | |
| 155 cipd_client = install_cipd_client( | |
| 156 reroot(root, client_spec['path']), | |
| 157 client_spec['package'], | |
| 158 client_spec['version']) | |
| 159 | |
| 160 # Parse spec into a list of deployment objects and then execute them. | |
| 161 env = Environment(root, cipd_client, service_account_json) | |
| 162 deployments = [] | |
| 163 for dep in (spec.get('deployments') or []): | |
| 164 if not dep.get('skip'): | |
| 165 deployments.append(CipdDeployment.create(dep, env)) | |
| 166 for dep in deployments: | |
| 167 exit_code = dep.apply() | |
| 168 if exit_code: | |
| 169 return exit_code | |
| 170 return 0 | |
| 171 | |
| 172 | |
| 173 def install_cipd_client(path, package, version): | |
| 174 """Installs CIPD client to <path>/cipd. | |
| 175 | |
| 176 Args: | |
| 177 path: root directory to install CIPD client into. | |
| 178 package: cipd client package name, e.g. infra/tools/cipd/linux-amd64. | |
| 179 version: version of the package to install. | |
| 180 | |
| 181 Returns: | |
| 182 Absolute path to CIPD executable. | |
| 183 """ | |
| 184 print('Ensuring CIPD client is up-to-date') | |
| 185 version_file = os.path.join(path, 'VERSION') | |
| 186 bin_file = os.path.join(path, 'cipd') | |
| 187 | |
| 188 # Resolve version to concrete instance ID, e.g "live" -> "abcdef0123....". | |
| 189 instance_id = call_cipd_api( | |
| 190 'repo/v1/instance/resolve', | |
| 191 {'package_name': package, 'version': version})['instance_id'] | |
| 192 print('CIPD client %s => %s', version, instance_id) | |
| 193 | |
| 194 # Already installed? | |
| 195 installed_instance_id = (read_file(version_file) or '').strip() | |
| 196 if installed_instance_id == instance_id and os.path.exists(bin_file): | |
| 197 return bin_file | |
| 198 | |
| 199 # Resolve instance ID to an URL to fetch client binary from. | |
| 200 client_info = call_cipd_api( | |
| 201 'repo/v1/client', | |
| 202 {'package_name': package, 'instance_id': instance_id}) | |
| 203 print('CIPD client binary info:\n%s', dump_json(client_info)) | |
| 204 | |
| 205 # Fetch the client. It is ~10 MB, so don't bother and fetch it into memory. | |
| 206 status, raw_client_bin = fetch_url(client_info['client_binary']['fetch_url']) | |
| 207 if status != 200: | |
| 208 print('Failed to fetch client binary, HTTP %d' % status) | |
| 209 raise CipdBootstrapError('Failed to fetch client binary, HTTP %d' % status) | |
| 210 digest = hashlib.sha1(raw_client_bin).hexdigest() | |
| 211 if digest != client_info['client_binary']['sha1']: | |
| 212 raise CipdBootstrapError('Client SHA1 mismatch') | |
| 213 | |
| 214 # Success. | |
| 215 print('Fetched CIPD client %s:%s at %s', package, instance_id, bin_file) | |
| 216 write_file(bin_file, raw_client_bin) | |
| 217 os.chmod(bin_file, 0755) | |
| 218 write_file(version_file, instance_id + '\n') | |
| 219 return bin_file | |
| 220 | |
| 221 | |
| 222 def call_cipd_api(endpoint, query): | |
| 223 """Sends GET request to CIPD backend, parses JSON response.""" | |
| 224 url = '%s/_ah/api/%s' % (CIPD_BACKEND_URL, endpoint) | |
| 225 if query: | |
| 226 url += '?' + urllib.urlencode(query) | |
| 227 status, body = fetch_url(url) | |
| 228 if status != 200: | |
| 229 raise CipdBootstrapError('Server replied with HTTP %d' % status) | |
| 230 try: | |
| 231 body = json.loads(body) | |
| 232 except ValueError: | |
| 233 raise CipdBootstrapError('Server returned invalid JSON') | |
| 234 status = body.get('status') | |
| 235 if status != 'SUCCESS': | |
| 236 m = body.get('error_message') or '<no error message>' | |
| 237 raise CipdBootstrapError('Server replied with error %s: %s' % (status, m)) | |
| 238 return body | |
| 239 | |
| 240 | |
| 241 def get_gce_metadata(key): | |
| 242 """Reads instance metadata attribute. | |
| 243 | |
| 244 Returns: | |
| 245 Blob with attribute value or None if not found. | |
| 246 """ | |
| 247 return fetch_url( | |
| 248 '%s/instance/attributes/%s' % (METADATA_SERVER, key), | |
| 249 headers={'Metadata-Flavor': 'Google'})[1] | |
| 250 | |
| 251 | |
| 252 def fetch_url(url, headers=None): | |
| 253 """Sends GET request (with retries). | |
| 254 | |
| 255 Args: | |
| 256 url: URL to fetch. | |
| 257 headers: dict with request headers. | |
| 258 | |
| 259 Returns: | |
| 260 (200, reply body) on success. | |
| 261 (HTTP code, None) on HTTP 401, 403, or 404 reply. | |
| 262 | |
| 263 Raises: | |
| 264 Whatever urllib2 raises. | |
| 265 """ | |
| 266 req = urllib2.Request(url) | |
| 267 req.add_header('User-Agent', 'ccompute update-cipd-packages') | |
| 268 for k, v in (headers or {}).iteritems(): | |
| 269 req.add_header(str(k), str(v)) | |
| 270 i = 0 | |
| 271 while True: | |
| 272 i += 1 | |
| 273 try: | |
| 274 print('GET %s', url) | |
| 275 return 200, urllib2.urlopen(req, timeout=60).read() | |
| 276 except Exception as e: | |
| 277 if isinstance(e, urllib2.HTTPError): | |
| 278 print('Failed to fetch %s, server returned HTTP %d', url, e.code) | |
| 279 if e.code in (401, 403, 404): | |
| 280 return e.code, None | |
| 281 else: | |
| 282 print('Failed to fetch %s', url) | |
| 283 if i == 20: | |
| 284 raise | |
| 285 print('Retrying in %d sec.', i) | |
| 286 time.sleep(i) | |
| 287 | |
| 288 | |
| 289 def reroot(root, path): | |
| 290 """Moves an absolute path to be under a new root.""" | |
| 291 if not root.startswith('/'): | |
| 292 raise ValueError('Not an absolute path: %s' % root) | |
| 293 if not path.startswith('/'): | |
| 294 raise ValueError('Not an absolute path: %s' % path) | |
| 295 return os.path.join(root, path[1:]) | |
| 296 | |
| 297 | |
| 298 def ensure_directory(path): | |
| 299 """Creates a directory.""" | |
| 300 # Handle a case where a file is being converted into a directory. | |
| 301 chunks = path.split(os.sep) | |
| 302 for i in xrange(len(chunks)): | |
| 303 p = os.sep.join(chunks[:i+1]) | |
| 304 if os.path.exists(p) and not os.path.isdir(p): | |
| 305 os.remove(p) | |
| 306 break | |
| 307 try: | |
| 308 os.makedirs(path) | |
| 309 except OSError as e: | |
| 310 if e.errno != errno.EEXIST: | |
| 311 raise | |
| 312 | |
| 313 | |
| 314 def read_file(path): | |
| 315 """Returns contents of a file or None if missing.""" | |
| 316 try: | |
| 317 with open(path, 'r') as f: | |
| 318 return f.read() | |
| 319 except IOError as e: | |
| 320 if e.errno == errno.ENOENT: | |
| 321 return None | |
| 322 raise | |
| 323 | |
| 324 | |
| 325 def write_file(path, data): | |
| 326 """Puts a file on disk, atomically.""" | |
| 327 assert sys.platform in ('linux2', 'darwin') | |
| 328 ensure_directory(os.path.dirname(path)) | |
| 329 fd, temp_file = tempfile.mkstemp(dir=os.path.dirname(path)) | |
| 330 with os.fdopen(fd, 'w') as f: | |
| 331 f.write(data) | |
| 332 os.rename(temp_file, path) | |
| 333 | |
| 334 | |
| 335 def chown(path, owner, group): | |
| 336 """Recursively changes owner of a given path if running as root. | |
| 337 | |
| 338 Does nothing if not running as root. | |
| 339 | |
| 340 Args: | |
| 341 owner: user name of a new owner. | |
| 342 group: name of a new owning group. | |
| 343 | |
| 344 Returns: | |
| 345 chown process exit code. | |
| 346 """ | |
| 347 if os.geteuid() != 0: | |
| 348 LOG.warning('Skipping chown(%s, %s:%s), not root', path, owner, group) | |
| 349 return 0 | |
| 350 return execute(['chown', '-R', '%s:%s' % (owner, group), path]) | |
| 351 | |
| 352 | |
| 353 def execute(cmd): | |
| 354 """Runs a subprocess redirecting its stdout and stderr to the log. | |
| 355 | |
| 356 Args: | |
| 357 cmd: list with command line arguments. | |
| 358 | |
| 359 Returns: | |
| 360 Process exit code. | |
| 361 """ | |
| 362 print('Running ' + ' '.join(cmd)) | |
| 363 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) | |
| 364 line = '' | |
| 365 while True: | |
| 366 buf = proc.stdout.read(1) | |
| 367 if not buf: | |
| 368 if line: | |
| 369 print(line.strip()) | |
| 370 break | |
| 371 if buf == '\n': | |
| 372 print(line.strip()) | |
| 373 line = '' | |
| 374 else: | |
| 375 line += buf | |
| 376 code = proc.wait() | |
| 377 if code: | |
| 378 print('Failed with exit code: %d', code) | |
| 379 return code | |
| 380 | |
| 381 | |
| 382 def dump_json(obj): | |
| 383 """Pretty-formats object to JSON.""" | |
| 384 return json.dumps(obj, indent=2, sort_keys=True, separators=(',',':')) | |
| 385 | |
| 386 | |
| 387 def setup_logging(log_file): | |
| 388 """Configures logging to log to a file.""" | |
| 389 global LOG | |
| 390 logger = logging.getLogger() | |
| 391 formatter = logging.Formatter( | |
| 392 '%(asctime)s - %(name)s - %(levelname)s - %(message)s') | |
| 393 handlers = [logging.StreamHandler(stream=sys.stdout)] | |
| 394 if log_file: | |
| 395 handlers.append( | |
| 396 logging.handlers.RotatingFileHandler(log_file, maxBytes=1*1024*1024)) | |
| 397 for h in handlers: | |
| 398 h.setFormatter(formatter) | |
| 399 logger.addHandler(h) | |
| 400 logger.setLevel(logging.DEBUG) | |
| 401 logger.info('-'*40) | |
| 402 LOG = logger | |
| 403 | |
| 404 | |
| 405 def setup_urllib2_ssl(cacert): | |
| 406 """Configures urllib2 to validate SSL certs. | |
| 407 | |
| 408 See http://stackoverflow.com/a/14320202/3817699. | |
| 409 """ | |
| 410 cacert = os.path.abspath(cacert) | |
| 411 | |
| 412 class ValidHTTPSConnection(httplib.HTTPConnection): | |
| 413 default_port = httplib.HTTPS_PORT | |
| 414 | |
| 415 def __init__(self, *args, **kwargs): | |
| 416 httplib.HTTPConnection.__init__(self, *args, **kwargs) | |
| 417 | |
| 418 def connect(self): | |
| 419 sock = socket.create_connection( | |
| 420 (self.host, self.port), self.timeout, self.source_address) | |
| 421 if self._tunnel_host: | |
| 422 self.sock = sock | |
| 423 self._tunnel() | |
| 424 self.sock = ssl.wrap_socket( | |
| 425 sock, ca_certs=cacert, cert_reqs=ssl.CERT_REQUIRED) | |
| 426 | |
| 427 class ValidHTTPSHandler(urllib2.HTTPSHandler): | |
| 428 def https_open(self, req): | |
| 429 return self.do_open(ValidHTTPSConnection, req) | |
| 430 | |
| 431 urllib2.install_opener(urllib2.build_opener(ValidHTTPSHandler)) | |
| 432 | |
| 433 | |
| 434 def update_cipd(): | |
| 435 parser = argparse.ArgumentParser() | |
| 436 parser.add_argument( | |
| 437 '--source', help='Path to JSON file to read instead of GCE metadata') | |
| 438 parser.add_argument( | |
| 439 '--root', help='Base directory for all paths specified in the JSON spec', | |
| 440 default='/') | |
| 441 parser.add_argument( | |
| 442 '--cacert', | |
| 443 help='Path to cacert.pem file with CA root certificates bundle', | |
| 444 default=DEFAULT_CERT_FILE) | |
| 445 parser.add_argument('--service-account-json', help='Credentials to use') | |
| 446 parser.add_argument('--log', help='Location of the log file') | |
| 447 opts = parser.parse_args() | |
| 448 | |
| 449 source = os.path.abspath(opts.source) if opts.source else None | |
| 450 root = os.path.abspath(opts.root) | |
| 451 if opts.service_account_json: | |
| 452 service_account_json = os.path.abspath(opts.service_account_json) | |
| 453 else: | |
| 454 service_account_json = None | |
| 455 | |
| 456 setup_logging(opts.log) | |
| 457 print('Package spec source: %s', source or 'GCE metadata') | |
| 458 print('Root: %s', root) | |
| 459 print('CA certs bundle: %s', opts.cacert) | |
| 460 print('Service account JSON: %s', service_account_json) | |
| 461 setup_urllib2_ssl(opts.cacert) | |
| 462 | |
| 463 attempt = 0 | |
| 464 while True: | |
| 465 attempt += 1 | |
| 466 try: | |
| 467 print('Attempt #%d', attempt) | |
| 468 exit_code = run_update(source, root, service_account_json) | |
| 469 if not exit_code: | |
| 470 break | |
| 471 except Exception: | |
| 472 print('Uncaught exception') | |
| 473 sleep = min(5 * 60, attempt * 10) | |
| 474 print('Retrying in %s sec.', sleep) | |
| 475 time.sleep(sleep) | |
| 476 print('-'*40) | |
| 477 | |
| 478 | |
| 479 def main(): | |
| 480 data = json.load(sys.stdin) | |
| 481 package = data['package'] | |
| 482 version = data['version'] | |
| 483 | |
| 484 bin_path = ("cipd_%s" % version) | |
| 485 print ("should install cipd at %s" % bin_path) | |
| 486 | |
| 487 exit_code = 0 | |
| 488 # return if this client version is already installed. | |
| 489 try: | |
| 490 if not os.path.isfile("%s/cipd" % bin_path): | |
| 491 # To get a current version ID: | |
| 492 # Look for "cipd - upload packages" step in a recent build, e.g. | |
| 493 # http://build.chromium.org/p/chromium.infra/builders/infra-continuous-tru sty-64/ | |
| 494 # and the last step contains the package name and hash you see here: | |
| 495 install_cipd_client(bin_path, package, version) | |
| 496 except Exception as e: | |
| 497 print ("Exception installing cipd: %s" % e) | |
| 498 exit_code = 1 | |
| 499 | |
| 500 # move the binary into the current directory. | |
| 501 os.rename('%s/cipd' % bin_path, "cipd") | |
| 502 | |
| 503 return exit_code | |
| 504 | |
| 505 if __name__ == '__main__': | |
| 506 sys.exit(main()) | |
| OLD | NEW |