blob: e7895144a3599b75ecc72560c2ba55066ed00284 [file] [log] [blame]
nodirbe642ff2016-06-09 15:51:51 -07001# Copyright 2016 The LUCI Authors. All rights reserved.
2# Use of this source code is governed under the Apache License, Version 2.0
3# that can be found in the LICENSE file.
4
5"""Fetches CIPD client and installs packages."""
6
nodirbe642ff2016-06-09 15:51:51 -07007import contextlib
8import hashlib
iannucci96fcccc2016-08-30 15:52:22 -07009import json
nodirbe642ff2016-06-09 15:51:51 -070010import logging
11import optparse
12import os
13import platform
14import sys
15import tempfile
16import time
17import urllib
18
19from utils import file_path
20from utils import fs
21from utils import net
22from utils import subprocess42
23from utils import tools
24import isolated_format
25import isolateserver
26
27
28# .exe on Windows.
29EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
30
31
iannucci4d7792a2017-03-10 10:30:56 -080032if sys.platform == 'win32':
33 def _ensure_batfile(client_path):
34 base, _ = os.path.splitext(client_path)
35 with open(base+".bat", 'w') as f:
36 f.write('\n'.join([ # python turns \n into CRLF
37 '@set CIPD="%~dp0cipd.exe"',
38 '@shift',
39 '@%CIPD% %*'
40 ]))
41else:
42 def _ensure_batfile(_client_path):
43 pass
44
45
nodirbe642ff2016-06-09 15:51:51 -070046class Error(Exception):
47 """Raised on CIPD errors."""
48
49
50def add_cipd_options(parser):
51 group = optparse.OptionGroup(parser, 'CIPD')
52 group.add_option(
vadimsh902948e2017-01-20 15:57:32 -080053 '--cipd-enabled',
54 help='Enable CIPD client bootstrap. Implied by --cipd-package.',
55 action='store_true',
56 default=False)
57 group.add_option(
nodirbe642ff2016-06-09 15:51:51 -070058 '--cipd-server',
vadimsh902948e2017-01-20 15:57:32 -080059 help='URL of the CIPD server. '
60 'Only relevant with --cipd-enabled or --cipd-package.')
nodirbe642ff2016-06-09 15:51:51 -070061 group.add_option(
62 '--cipd-client-package',
nodir90bc8dc2016-06-15 13:35:21 -070063 help='Package name of CIPD client with optional parameters described in '
nodirff531b42016-06-23 13:05:06 -070064 '--cipd-package help. '
vadimsh902948e2017-01-20 15:57:32 -080065 'Only relevant with --cipd-enabled or --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070066 'Default: "%default"',
nodir90bc8dc2016-06-15 13:35:21 -070067 default='infra/tools/cipd/${platform}')
nodirbe642ff2016-06-09 15:51:51 -070068 group.add_option(
nodir90bc8dc2016-06-15 13:35:21 -070069 '--cipd-client-version',
70 help='Version of CIPD client. '
vadimsh902948e2017-01-20 15:57:32 -080071 'Only relevant with --cipd-enabled or --cipd-package. '
nodir90bc8dc2016-06-15 13:35:21 -070072 'Default: "%default"',
73 default='latest')
74 group.add_option(
nodirff531b42016-06-23 13:05:06 -070075 '--cipd-package',
76 dest='cipd_packages',
77 help='A CIPD package to install. '
78 'Format is "<path>:<package_name>:<version>". '
79 '"path" is installation directory relative to run_dir, '
80 'defaults to ".". '
nodir90bc8dc2016-06-15 13:35:21 -070081 '"package_name" may have ${platform} and/or ${os_ver} parameters. '
nodirbe642ff2016-06-09 15:51:51 -070082 '${platform} will be expanded to "<os>-<architecture>" and '
83 '${os_ver} will be expanded to OS version name. '
nodirff531b42016-06-23 13:05:06 -070084 'The option can be specified multiple times.',
85 action='append',
86 default=[])
nodirbe642ff2016-06-09 15:51:51 -070087 group.add_option(
88 '--cipd-cache',
89 help='CIPD cache directory, separate from isolate cache. '
vadimsh902948e2017-01-20 15:57:32 -080090 'Only relevant with --cipd-enabled or --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070091 'Default: "%default".',
92 default='')
93 parser.add_option_group(group)
94
95
96def validate_cipd_options(parser, options):
97 """Calls parser.error on first found error among cipd options."""
vadimsh902948e2017-01-20 15:57:32 -080098 if options.cipd_packages:
99 options.cipd_enabled = True
100
101 if not options.cipd_enabled:
nodirbe642ff2016-06-09 15:51:51 -0700102 return
nodirff531b42016-06-23 13:05:06 -0700103
104 for pkg in options.cipd_packages:
105 parts = pkg.split(':', 2)
106 if len(parts) != 3:
107 parser.error('invalid package "%s": must have at least 2 colons' % pkg)
108 _path, name, version = parts
109 if not name:
110 parser.error('invalid package "%s": package name is not specified' % pkg)
111 if not version:
112 parser.error('invalid package "%s": version is not specified' % pkg)
113
nodirbe642ff2016-06-09 15:51:51 -0700114 if not options.cipd_server:
vadimsh902948e2017-01-20 15:57:32 -0800115 parser.error('cipd is enabled, --cipd-server is required')
nodirbe642ff2016-06-09 15:51:51 -0700116
117 if not options.cipd_client_package:
nodirbe642ff2016-06-09 15:51:51 -0700118 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800119 'cipd is enabled, --cipd-client-package is required')
nodir90bc8dc2016-06-15 13:35:21 -0700120 if not options.cipd_client_version:
121 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800122 'cipd is enabled, --cipd-client-version is required')
nodirbe642ff2016-06-09 15:51:51 -0700123
124
125class CipdClient(object):
126 """Installs packages."""
127
iannucci96fcccc2016-08-30 15:52:22 -0700128 def __init__(self, binary_path, package_name, instance_id, service_url):
nodirbe642ff2016-06-09 15:51:51 -0700129 """Initializes CipdClient.
130
131 Args:
132 binary_path (str): path to the CIPD client binary.
iannucci96fcccc2016-08-30 15:52:22 -0700133 package_name (str): the CIPD package name for the client itself.
134 instance_id (str): the CIPD instance_id for the client itself.
nodirbe642ff2016-06-09 15:51:51 -0700135 service_url (str): if not None, URL of the CIPD backend that overrides
136 the default one.
137 """
138 self.binary_path = binary_path
iannucci96fcccc2016-08-30 15:52:22 -0700139 self.package_name = package_name
140 self.instance_id = instance_id
nodirbe642ff2016-06-09 15:51:51 -0700141 self.service_url = service_url
142
143 def ensure(
144 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None):
145 """Ensures that packages installed in |site_root| equals |packages| set.
146
147 Blocking call.
148
149 Args:
150 site_root (str): where to install packages.
nodir90bc8dc2016-06-15 13:35:21 -0700151 packages: list of (package_template, version) tuples.
nodirbe642ff2016-06-09 15:51:51 -0700152 cache_dir (str): if set, cache dir for cipd binary own cache.
153 Typically contains packages and tags.
154 tmp_dir (str): if not None, dir for temp files.
155 timeout (int): if not None, timeout in seconds for this function to run.
156
iannucci96fcccc2016-08-30 15:52:22 -0700157 Returns:
158 Pinned packages in the form of [(package_name, package_id)], which
159 correspond 1:1 with the input packages argument.
160
nodirbe642ff2016-06-09 15:51:51 -0700161 Raises:
162 Error if could not install packages or timed out.
163 """
164 timeoutfn = tools.sliding_timeout(timeout)
165 logging.info('Installing packages %r into %s', packages, site_root)
166
167 list_file_handle, list_file_path = tempfile.mkstemp(
168 dir=tmp_dir, prefix=u'cipd-ensure-list-', suffix='.txt')
iannucci96fcccc2016-08-30 15:52:22 -0700169 json_out_file_handle, json_file_path = tempfile.mkstemp(
170 dir=tmp_dir, prefix=u'cipd-ensure-result-', suffix='.json')
171 os.close(json_out_file_handle)
172
nodirbe642ff2016-06-09 15:51:51 -0700173 try:
174 try:
nodir90bc8dc2016-06-15 13:35:21 -0700175 for pkg, version in packages:
nodirbe642ff2016-06-09 15:51:51 -0700176 pkg = render_package_name_template(pkg)
177 os.write(list_file_handle, '%s %s\n' % (pkg, version))
178 finally:
179 os.close(list_file_handle)
180
181 cmd = [
182 self.binary_path, 'ensure',
183 '-root', site_root,
184 '-list', list_file_path,
185 '-verbose', # this is safe because cipd-ensure does not print a lot
iannucci96fcccc2016-08-30 15:52:22 -0700186 '-json-output', json_file_path,
nodirbe642ff2016-06-09 15:51:51 -0700187 ]
188 if cache_dir:
189 cmd += ['-cache-dir', cache_dir]
190 if self.service_url:
191 cmd += ['-service-url', self.service_url]
192
193 logging.debug('Running %r', cmd)
194 process = subprocess42.Popen(
195 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
196 output = []
197 for pipe_name, line in process.yield_any_line(timeout=0.1):
198 to = timeoutfn()
199 if to is not None and to <= 0:
200 raise Error(
201 'Could not install packages; took more than %d seconds' % timeout)
202 if not pipe_name:
203 # stdout or stderr was closed, but yield_any_line still may have
204 # something to yield.
205 continue
206 output.append(line)
207 if pipe_name == 'stderr':
208 logging.debug('cipd client: %s', line)
209 else:
210 logging.info('cipd client: %s', line)
211
212 exit_code = process.wait(timeout=timeoutfn())
213 if exit_code != 0:
214 raise Error(
215 'Could not install packages; exit code %d\noutput:%s' % (
216 exit_code, '\n'.join(output)))
iannucci96fcccc2016-08-30 15:52:22 -0700217 with open(json_file_path) as jfile:
218 result_json = json.load(jfile)
iannucci1c9a3692017-01-30 14:10:49 -0800219 # TEMPORARY(iannucci): this code handles cipd <1.4 and cipd >=1.5
220 # formatted ensure result formats. Cipd 1.5 added support for subdirs, and
221 # as part of the transition, the result of the ensure command needed to
222 # change. To ease the transition, we always return data as-if we're using
223 # the new format. Once cipd 1.5+ is deployed everywhere, this type switch
224 # can be removed.
225 if isinstance(result_json['result'], dict):
226 # cipd 1.5
227 return {
228 subdir: [(x['package'], x['instance_id']) for x in pins]
229 for subdir, pins in result_json['result'].iteritems()
230 }
231 else:
232 # cipd 1.4
233 return {
234 "": [(x['package'], x['instance_id']) for x in result_json['result']],
235 }
nodirbe642ff2016-06-09 15:51:51 -0700236 finally:
237 fs.remove(list_file_path)
iannucci96fcccc2016-08-30 15:52:22 -0700238 fs.remove(json_file_path)
nodirbe642ff2016-06-09 15:51:51 -0700239
240
241def get_platform():
242 """Returns ${platform} parameter value.
243
244 Borrowed from
245 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
246 """
247 # linux, mac or windows.
248 platform_variant = {
249 'darwin': 'mac',
250 'linux2': 'linux',
251 'win32': 'windows',
252 }.get(sys.platform)
253 if not platform_variant:
254 raise Error('Unknown OS: %s' % sys.platform)
255
256 # amd64, 386, etc.
257 machine = platform.machine().lower()
258 platform_arch = {
259 'amd64': 'amd64',
260 'i386': '386',
261 'i686': '386',
262 'x86': '386',
263 'x86_64': 'amd64',
264 }.get(machine)
265 if not platform_arch:
266 if machine.startswith('arm'):
267 platform_arch = 'armv6l'
268 else:
269 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
270 return '%s-%s' % (platform_variant, platform_arch)
271
272
273def get_os_ver():
274 """Returns ${os_ver} parameter value.
275
276 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
277
278 Borrowed from
279 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
280 """
281 if sys.platform == 'darwin':
282 # platform.mac_ver()[0] is '10.9.5'.
283 dist = platform.mac_ver()[0].split('.')
284 return 'mac%s_%s' % (dist[0], dist[1])
285
286 if sys.platform == 'linux2':
287 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
288 dist = platform.linux_distribution()
289 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
290
291 if sys.platform == 'win32':
292 # platform.version() is '6.1.7601'.
293 dist = platform.version().split('.')
294 return 'win%s_%s' % (dist[0], dist[1])
295 raise Error('Unknown OS: %s' % sys.platform)
296
297
298def render_package_name_template(template):
299 """Expands template variables in a CIPD package name template."""
300 return (template
301 .lower() # Package names are always lower case
302 .replace('${platform}', get_platform())
303 .replace('${os_ver}', get_os_ver()))
304
305
nodirbe642ff2016-06-09 15:51:51 -0700306def _check_response(res, fmt, *args):
307 """Raises Error if response is bad."""
308 if not res:
309 raise Error('%s: no response' % (fmt % args))
310
311 if res.get('status') != 'SUCCESS':
312 raise Error('%s: %s' % (
313 fmt % args,
314 res.get('error_message') or 'status is %s' % res.get('status')))
315
316
317def resolve_version(cipd_server, package_name, version, timeout=None):
318 """Resolves a package instance version (e.g. a tag) to an instance id."""
319 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
320 cipd_server,
321 urllib.urlencode({
322 'package_name': package_name,
323 'version': version,
324 }))
325 res = net.url_read_json(url, timeout=timeout)
326 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
327 instance_id = res.get('instance_id')
328 if not instance_id:
329 raise Error('Invalid resolveVersion response: no instance id')
330 return instance_id
331
332
333def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
334 """Returns a fetch URL of CIPD client binary contents.
335
336 Raises:
337 Error if cannot retrieve fetch URL.
338 """
339 # Fetch the URL of the binary from CIPD backend.
340 package_name = render_package_name_template(package_name)
341 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
342 'package_name': package_name,
343 'instance_id': instance_id,
344 }))
345 res = net.url_read_json(url, timeout=timeout)
346 _check_response(
347 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
348 fetch_url = res.get('client_binary', {}).get('fetch_url')
349 if not fetch_url:
350 raise Error('Invalid fetchClientBinary response: no fetch_url')
351 return fetch_url
352
353
354def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
355 """Fetches cipd binary to |disk_cache|.
356
357 Retries requests with exponential back-off.
358
359 Raises:
360 Error if could not fetch content.
361 """
362 sleep_time = 1
363 for attempt in xrange(5):
364 if attempt > 0:
365 if timeoutfn() is not None and timeoutfn() < sleep_time:
366 raise Error('Could not fetch CIPD client: timeout')
367 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
368 time.sleep(sleep_time)
369 sleep_time *= 2
370
371 try:
372 res = net.url_open(fetch_url, timeout=timeoutfn())
373 if res:
374 disk_cache.write(instance_id, res.iter_content(64 * 1024))
375 return
376 except net.TimeoutError as ex:
377 raise Error('Could not fetch CIPD client: %s', ex)
378 except net.NetError as ex:
379 logging.warning(
380 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
381
382 raise Error('Could not fetch CIPD client after 5 retries')
383
384
385@contextlib.contextmanager
vadimsh232f5a82017-01-20 19:23:44 -0800386def get_client(service_url, package_name, version, cache_dir, timeout=None):
nodirbe642ff2016-06-09 15:51:51 -0700387 """Returns a context manager that yields a CipdClient. A blocking call.
388
vadimsh232f5a82017-01-20 19:23:44 -0800389 Upon exit from the context manager, the client binary may be deleted
390 (if the internal cache is full).
391
nodirbe642ff2016-06-09 15:51:51 -0700392 Args:
vadimsh232f5a82017-01-20 19:23:44 -0800393 service_url (str): URL of the CIPD backend.
394 package_name (str): package name template of the CIPD client.
395 version (str): version of CIPD client package.
396 cache_dir: directory to store instance cache, version cache
397 and a hardlink to the client binary.
398 timeout (int): if not None, timeout in seconds for this function.
nodirbe642ff2016-06-09 15:51:51 -0700399
400 Yields:
401 CipdClient.
402
403 Raises:
404 Error if CIPD client version cannot be resolved or client cannot be fetched.
405 """
406 timeoutfn = tools.sliding_timeout(timeout)
407
408 package_name = render_package_name_template(package_name)
409
410 # Resolve version to instance id.
411 # Is it an instance id already? They look like HEX SHA1.
412 if isolated_format.is_valid_hash(version, hashlib.sha1):
413 instance_id = version
iannucci6fd57d22016-08-30 17:02:20 -0700414 elif ':' in version: # it's an immutable tag
nodirbe642ff2016-06-09 15:51:51 -0700415 # version_cache is {version_digest -> instance id} mapping.
416 # It does not take a lot of disk space.
417 version_cache = isolateserver.DiskCache(
418 unicode(os.path.join(cache_dir, 'versions')),
419 isolateserver.CachePolicies(0, 0, 300),
420 hashlib.sha1)
421 with version_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700422 version_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700423 # Convert |version| to a string that may be used as a filename in disk
424 # cache by hashing it.
425 version_digest = hashlib.sha1(version).hexdigest()
426 try:
tansell9e04a8d2016-07-28 09:31:59 -0700427 with version_cache.getfileobj(version_digest) as f:
428 instance_id = f.read()
nodirbe642ff2016-06-09 15:51:51 -0700429 except isolateserver.CacheMiss:
430 instance_id = resolve_version(
431 service_url, package_name, version, timeout=timeoutfn())
432 version_cache.write(version_digest, instance_id)
iannucci6fd57d22016-08-30 17:02:20 -0700433 else: # it's a ref
434 instance_id = resolve_version(
435 service_url, package_name, version, timeout=timeoutfn())
nodirbe642ff2016-06-09 15:51:51 -0700436
437 # instance_cache is {instance_id -> client binary} mapping.
438 # It is bounded by 5 client versions.
439 instance_cache = isolateserver.DiskCache(
440 unicode(os.path.join(cache_dir, 'clients')),
441 isolateserver.CachePolicies(0, 0, 5),
442 hashlib.sha1)
443 with instance_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700444 instance_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700445 if instance_id not in instance_cache:
446 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
447 fetch_url = get_client_fetch_url(
448 service_url, package_name, instance_id, timeout=timeoutfn())
449 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
450
451 # A single host can run multiple swarming bots, but ATM they do not share
452 # same root bot directory. Thus, it is safe to use the same name for the
453 # binary.
vadimsh232f5a82017-01-20 19:23:44 -0800454 cipd_bin_dir = unicode(os.path.join(cache_dir, 'bin'))
455 binary_path = os.path.join(cipd_bin_dir, 'cipd' + EXECUTABLE_SUFFIX)
nodirbe642ff2016-06-09 15:51:51 -0700456 if fs.isfile(binary_path):
nodir6dfdb2d2016-06-14 20:14:08 -0700457 file_path.remove(binary_path)
vadimsh232f5a82017-01-20 19:23:44 -0800458 else:
459 file_path.ensure_tree(cipd_bin_dir)
tansell9e04a8d2016-07-28 09:31:59 -0700460
461 with instance_cache.getfileobj(instance_id) as f:
462 isolateserver.putfile(f, binary_path, 0511) # -r-x--x--x
nodirbe642ff2016-06-09 15:51:51 -0700463
iannucci4d7792a2017-03-10 10:30:56 -0800464 _ensure_batfile(binary_path)
465
vadimsh232f5a82017-01-20 19:23:44 -0800466 yield CipdClient(
467 binary_path,
468 package_name=package_name,
469 instance_id=instance_id,
470 service_url=service_url)
nodir90bc8dc2016-06-15 13:35:21 -0700471
472
nodirff531b42016-06-23 13:05:06 -0700473def parse_package_args(packages):
474 """Parses --cipd-package arguments.
nodir90bc8dc2016-06-15 13:35:21 -0700475
nodirff531b42016-06-23 13:05:06 -0700476 Assumes |packages| were validated by validate_cipd_options.
477
478 Returns:
iannucci96fcccc2016-08-30 15:52:22 -0700479 A list of [(path, package_name, version), ...]
nodir90bc8dc2016-06-15 13:35:21 -0700480 """
iannucci96fcccc2016-08-30 15:52:22 -0700481 result = []
nodirff531b42016-06-23 13:05:06 -0700482 for pkg in packages:
483 path, name, version = pkg.split(':', 2)
nodir90bc8dc2016-06-15 13:35:21 -0700484 if not name:
nodirff531b42016-06-23 13:05:06 -0700485 raise Error('Invalid package "%s": package name is not specified' % pkg)
nodir90bc8dc2016-06-15 13:35:21 -0700486 if not version:
nodirff531b42016-06-23 13:05:06 -0700487 raise Error('Invalid package "%s": version is not specified' % pkg)
iannucci96fcccc2016-08-30 15:52:22 -0700488 result.append((path, name, version))
nodirff531b42016-06-23 13:05:06 -0700489 return result