Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(300)

Side by Side Diff: scripts/slave/recipe_modules/cipd/resources/bootstrap.py

Issue 1193813004: cipd recipe_module (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/build.git@master
Patch Set: added ensure_installed Created 5 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(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())
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698