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