blob: 69fe0375e486f38dc49b815feebe8a296396ab11 [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 ".". '
Vadim Shtayura34f1fd22017-10-18 19:23:15 -070081 '"package_name" may have ${platform} parameter: it will be '
82 'expanded to "<os>-<architecture>". '
nodirff531b42016-06-23 13:05:06 -070083 'The option can be specified multiple times.',
84 action='append',
85 default=[])
nodirbe642ff2016-06-09 15:51:51 -070086 group.add_option(
87 '--cipd-cache',
88 help='CIPD cache directory, separate from isolate cache. '
vadimsh902948e2017-01-20 15:57:32 -080089 'Only relevant with --cipd-enabled or --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070090 'Default: "%default".',
91 default='')
92 parser.add_option_group(group)
93
94
95def validate_cipd_options(parser, options):
96 """Calls parser.error on first found error among cipd options."""
vadimsh902948e2017-01-20 15:57:32 -080097 if options.cipd_packages:
98 options.cipd_enabled = True
99
100 if not options.cipd_enabled:
nodirbe642ff2016-06-09 15:51:51 -0700101 return
nodirff531b42016-06-23 13:05:06 -0700102
103 for pkg in options.cipd_packages:
104 parts = pkg.split(':', 2)
105 if len(parts) != 3:
106 parser.error('invalid package "%s": must have at least 2 colons' % pkg)
107 _path, name, version = parts
108 if not name:
109 parser.error('invalid package "%s": package name is not specified' % pkg)
110 if not version:
111 parser.error('invalid package "%s": version is not specified' % pkg)
112
nodirbe642ff2016-06-09 15:51:51 -0700113 if not options.cipd_server:
vadimsh902948e2017-01-20 15:57:32 -0800114 parser.error('cipd is enabled, --cipd-server is required')
nodirbe642ff2016-06-09 15:51:51 -0700115
116 if not options.cipd_client_package:
nodirbe642ff2016-06-09 15:51:51 -0700117 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800118 'cipd is enabled, --cipd-client-package is required')
nodir90bc8dc2016-06-15 13:35:21 -0700119 if not options.cipd_client_version:
120 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800121 'cipd is enabled, --cipd-client-version is required')
nodirbe642ff2016-06-09 15:51:51 -0700122
123
124class CipdClient(object):
125 """Installs packages."""
126
iannucci96fcccc2016-08-30 15:52:22 -0700127 def __init__(self, binary_path, package_name, instance_id, service_url):
nodirbe642ff2016-06-09 15:51:51 -0700128 """Initializes CipdClient.
129
130 Args:
131 binary_path (str): path to the CIPD client binary.
iannucci96fcccc2016-08-30 15:52:22 -0700132 package_name (str): the CIPD package name for the client itself.
133 instance_id (str): the CIPD instance_id for the client itself.
nodirbe642ff2016-06-09 15:51:51 -0700134 service_url (str): if not None, URL of the CIPD backend that overrides
135 the default one.
136 """
137 self.binary_path = binary_path
iannucci96fcccc2016-08-30 15:52:22 -0700138 self.package_name = package_name
139 self.instance_id = instance_id
nodirbe642ff2016-06-09 15:51:51 -0700140 self.service_url = service_url
141
142 def ensure(
143 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None):
144 """Ensures that packages installed in |site_root| equals |packages| set.
145
146 Blocking call.
147
148 Args:
149 site_root (str): where to install packages.
iannuccib58d10d2017-03-18 02:00:25 -0700150 packages: dict of subdir -> list of (package_template, version) tuples.
nodirbe642ff2016-06-09 15:51:51 -0700151 cache_dir (str): if set, cache dir for cipd binary own cache.
152 Typically contains packages and tags.
153 tmp_dir (str): if not None, dir for temp files.
154 timeout (int): if not None, timeout in seconds for this function to run.
155
iannucci96fcccc2016-08-30 15:52:22 -0700156 Returns:
iannuccib58d10d2017-03-18 02:00:25 -0700157 Pinned packages in the form of {subdir: [(package_name, package_id)]},
158 which correspond 1:1 with the input packages argument.
iannucci96fcccc2016-08-30 15:52:22 -0700159
nodirbe642ff2016-06-09 15:51:51 -0700160 Raises:
161 Error if could not install packages or timed out.
162 """
163 timeoutfn = tools.sliding_timeout(timeout)
164 logging.info('Installing packages %r into %s', packages, site_root)
165
iannuccib58d10d2017-03-18 02:00:25 -0700166 ensure_file_handle, ensure_file_path = tempfile.mkstemp(
167 dir=tmp_dir, prefix=u'cipd-ensure-file-', suffix='.txt')
iannucci96fcccc2016-08-30 15:52:22 -0700168 json_out_file_handle, json_file_path = tempfile.mkstemp(
169 dir=tmp_dir, prefix=u'cipd-ensure-result-', suffix='.json')
170 os.close(json_out_file_handle)
171
nodirbe642ff2016-06-09 15:51:51 -0700172 try:
173 try:
iannuccib58d10d2017-03-18 02:00:25 -0700174 for subdir, pkgs in sorted(packages.iteritems()):
175 if '\n' in subdir:
176 raise Error(
177 'Could not install packages; subdir %r contains newline' % subdir)
178 os.write(ensure_file_handle, '@Subdir %s\n' % (subdir,))
179 for pkg, version in pkgs:
iannuccib58d10d2017-03-18 02:00:25 -0700180 os.write(ensure_file_handle, '%s %s\n' % (pkg, version))
nodirbe642ff2016-06-09 15:51:51 -0700181 finally:
iannuccib58d10d2017-03-18 02:00:25 -0700182 os.close(ensure_file_handle)
nodirbe642ff2016-06-09 15:51:51 -0700183
184 cmd = [
185 self.binary_path, 'ensure',
186 '-root', site_root,
iannuccib58d10d2017-03-18 02:00:25 -0700187 '-ensure-file', ensure_file_path,
nodirbe642ff2016-06-09 15:51:51 -0700188 '-verbose', # this is safe because cipd-ensure does not print a lot
iannucci96fcccc2016-08-30 15:52:22 -0700189 '-json-output', json_file_path,
nodirbe642ff2016-06-09 15:51:51 -0700190 ]
191 if cache_dir:
192 cmd += ['-cache-dir', cache_dir]
193 if self.service_url:
194 cmd += ['-service-url', self.service_url]
195
196 logging.debug('Running %r', cmd)
197 process = subprocess42.Popen(
198 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
199 output = []
200 for pipe_name, line in process.yield_any_line(timeout=0.1):
201 to = timeoutfn()
202 if to is not None and to <= 0:
203 raise Error(
204 'Could not install packages; took more than %d seconds' % timeout)
205 if not pipe_name:
206 # stdout or stderr was closed, but yield_any_line still may have
207 # something to yield.
208 continue
209 output.append(line)
210 if pipe_name == 'stderr':
211 logging.debug('cipd client: %s', line)
212 else:
213 logging.info('cipd client: %s', line)
214
215 exit_code = process.wait(timeout=timeoutfn())
216 if exit_code != 0:
217 raise Error(
218 'Could not install packages; exit code %d\noutput:%s' % (
219 exit_code, '\n'.join(output)))
iannucci96fcccc2016-08-30 15:52:22 -0700220 with open(json_file_path) as jfile:
221 result_json = json.load(jfile)
iannuccib58d10d2017-03-18 02:00:25 -0700222 return {
223 subdir: [(x['package'], x['instance_id']) for x in pins]
224 for subdir, pins in result_json['result'].iteritems()
225 }
nodirbe642ff2016-06-09 15:51:51 -0700226 finally:
iannuccib58d10d2017-03-18 02:00:25 -0700227 fs.remove(ensure_file_path)
iannucci96fcccc2016-08-30 15:52:22 -0700228 fs.remove(json_file_path)
nodirbe642ff2016-06-09 15:51:51 -0700229
230
231def get_platform():
232 """Returns ${platform} parameter value.
233
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700234 The logic is similar to
235 https://chromium.googlesource.com/chromium/tools/build/+/6c5c7e9c/scripts/slave/infra_platform.py
nodirbe642ff2016-06-09 15:51:51 -0700236 """
237 # linux, mac or windows.
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700238 os_name = {
nodirbe642ff2016-06-09 15:51:51 -0700239 'darwin': 'mac',
240 'linux2': 'linux',
241 'win32': 'windows',
242 }.get(sys.platform)
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700243 if not os_name:
nodirbe642ff2016-06-09 15:51:51 -0700244 raise Error('Unknown OS: %s' % sys.platform)
245
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700246 # Normalize machine architecture. Some architectures are identical or
247 # compatible with others. We collapse them into one.
248 arch = platform.machine().lower()
249 if arch in ('arm64', 'aarch64'):
250 arch = 'arm64'
251 elif arch.startswith('armv') and arch.endswith('l'):
252 # 32-bit ARM: Standardize on ARM v6 baseline.
253 arch = 'armv6l'
254 elif arch in ('amd64', 'x86_64'):
255 arch = 'amd64'
Vadim Shtayura9f9d6ab2017-10-19 13:04:50 -0700256 elif arch in ('i386', 'i686', 'x86'):
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700257 arch = '386'
nodirbe642ff2016-06-09 15:51:51 -0700258
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700259 # If using a 32-bit python on x86_64 kernel on Linux, "downgrade" the arch to
260 # 32-bit too (this is the bitness of the userland).
261 python_bits = 64 if sys.maxsize > 2**32 else 32
262 if os_name == 'linux' and arch == 'amd64' and python_bits == 32:
263 arch = '386'
nodirbe642ff2016-06-09 15:51:51 -0700264
Vadim Shtayura34f1fd22017-10-18 19:23:15 -0700265 return '%s-%s' % (os_name, arch)
nodirbe642ff2016-06-09 15:51:51 -0700266
267
nodirbe642ff2016-06-09 15:51:51 -0700268def _check_response(res, fmt, *args):
269 """Raises Error if response is bad."""
270 if not res:
271 raise Error('%s: no response' % (fmt % args))
272
273 if res.get('status') != 'SUCCESS':
274 raise Error('%s: %s' % (
275 fmt % args,
276 res.get('error_message') or 'status is %s' % res.get('status')))
277
278
279def resolve_version(cipd_server, package_name, version, timeout=None):
280 """Resolves a package instance version (e.g. a tag) to an instance id."""
281 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
282 cipd_server,
283 urllib.urlencode({
284 'package_name': package_name,
285 'version': version,
286 }))
287 res = net.url_read_json(url, timeout=timeout)
288 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
289 instance_id = res.get('instance_id')
290 if not instance_id:
291 raise Error('Invalid resolveVersion response: no instance id')
292 return instance_id
293
294
295def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
296 """Returns a fetch URL of CIPD client binary contents.
297
298 Raises:
299 Error if cannot retrieve fetch URL.
300 """
301 # Fetch the URL of the binary from CIPD backend.
nodirbe642ff2016-06-09 15:51:51 -0700302 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
303 'package_name': package_name,
304 'instance_id': instance_id,
305 }))
306 res = net.url_read_json(url, timeout=timeout)
307 _check_response(
308 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
309 fetch_url = res.get('client_binary', {}).get('fetch_url')
310 if not fetch_url:
311 raise Error('Invalid fetchClientBinary response: no fetch_url')
312 return fetch_url
313
314
315def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
316 """Fetches cipd binary to |disk_cache|.
317
318 Retries requests with exponential back-off.
319
320 Raises:
321 Error if could not fetch content.
322 """
323 sleep_time = 1
324 for attempt in xrange(5):
325 if attempt > 0:
326 if timeoutfn() is not None and timeoutfn() < sleep_time:
327 raise Error('Could not fetch CIPD client: timeout')
328 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
329 time.sleep(sleep_time)
330 sleep_time *= 2
331
332 try:
333 res = net.url_open(fetch_url, timeout=timeoutfn())
334 if res:
335 disk_cache.write(instance_id, res.iter_content(64 * 1024))
336 return
337 except net.TimeoutError as ex:
338 raise Error('Could not fetch CIPD client: %s', ex)
339 except net.NetError as ex:
340 logging.warning(
341 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
342
343 raise Error('Could not fetch CIPD client after 5 retries')
344
345
346@contextlib.contextmanager
Marc-Antoine Rueldd1bab42017-12-06 12:47:37 -0500347def get_client(service_url, package_template, version, cache_dir, timeout=None):
nodirbe642ff2016-06-09 15:51:51 -0700348 """Returns a context manager that yields a CipdClient. A blocking call.
349
vadimsh232f5a82017-01-20 19:23:44 -0800350 Upon exit from the context manager, the client binary may be deleted
351 (if the internal cache is full).
352
nodirbe642ff2016-06-09 15:51:51 -0700353 Args:
vadimsh232f5a82017-01-20 19:23:44 -0800354 service_url (str): URL of the CIPD backend.
Marc-Antoine Rueldd1bab42017-12-06 12:47:37 -0500355 package_template (str): package name template of the CIPD client.
vadimsh232f5a82017-01-20 19:23:44 -0800356 version (str): version of CIPD client package.
357 cache_dir: directory to store instance cache, version cache
358 and a hardlink to the client binary.
359 timeout (int): if not None, timeout in seconds for this function.
nodirbe642ff2016-06-09 15:51:51 -0700360
361 Yields:
362 CipdClient.
363
364 Raises:
365 Error if CIPD client version cannot be resolved or client cannot be fetched.
366 """
367 timeoutfn = tools.sliding_timeout(timeout)
368
Marc-Antoine Rueldd1bab42017-12-06 12:47:37 -0500369 # Package names are always lower case.
370 # TODO(maruel): Assert instead?
371 package_name = package_template.lower().replace('${platform}', get_platform())
nodirbe642ff2016-06-09 15:51:51 -0700372
373 # Resolve version to instance id.
374 # Is it an instance id already? They look like HEX SHA1.
375 if isolated_format.is_valid_hash(version, hashlib.sha1):
376 instance_id = version
Vadim Shtayuraa2e125e2017-10-19 11:36:42 -0700377 elif ':' in version: # it's an immutable tag, cache the resolved version
378 # version_cache is {hash(package_name, tag) -> instance id} mapping.
nodirbe642ff2016-06-09 15:51:51 -0700379 # It does not take a lot of disk space.
380 version_cache = isolateserver.DiskCache(
381 unicode(os.path.join(cache_dir, 'versions')),
382 isolateserver.CachePolicies(0, 0, 300),
maruele6fc9382017-05-04 09:03:48 -0700383 hashlib.sha1,
384 trim=True)
nodirbe642ff2016-06-09 15:51:51 -0700385 with version_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700386 version_cache.cleanup()
Vadim Shtayuraa2e125e2017-10-19 11:36:42 -0700387 # Convert (package_name, version) to a string that may be used as a
388 # filename in disk cache by hashing it.
389 version_digest = hashlib.sha1(
390 '%s\n%s' % (package_name, version)).hexdigest()
nodirbe642ff2016-06-09 15:51:51 -0700391 try:
tansell9e04a8d2016-07-28 09:31:59 -0700392 with version_cache.getfileobj(version_digest) as f:
393 instance_id = f.read()
nodirbe642ff2016-06-09 15:51:51 -0700394 except isolateserver.CacheMiss:
395 instance_id = resolve_version(
396 service_url, package_name, version, timeout=timeoutfn())
397 version_cache.write(version_digest, instance_id)
Vadim Shtayuraa2e125e2017-10-19 11:36:42 -0700398 else: # it's a ref, hit the backend
iannucci6fd57d22016-08-30 17:02:20 -0700399 instance_id = resolve_version(
400 service_url, package_name, version, timeout=timeoutfn())
nodirbe642ff2016-06-09 15:51:51 -0700401
402 # instance_cache is {instance_id -> client binary} mapping.
403 # It is bounded by 5 client versions.
404 instance_cache = isolateserver.DiskCache(
405 unicode(os.path.join(cache_dir, 'clients')),
406 isolateserver.CachePolicies(0, 0, 5),
maruele6fc9382017-05-04 09:03:48 -0700407 hashlib.sha1,
408 trim=True)
nodirbe642ff2016-06-09 15:51:51 -0700409 with instance_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700410 instance_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700411 if instance_id not in instance_cache:
412 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
413 fetch_url = get_client_fetch_url(
414 service_url, package_name, instance_id, timeout=timeoutfn())
415 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
416
417 # A single host can run multiple swarming bots, but ATM they do not share
418 # same root bot directory. Thus, it is safe to use the same name for the
419 # binary.
vadimsh232f5a82017-01-20 19:23:44 -0800420 cipd_bin_dir = unicode(os.path.join(cache_dir, 'bin'))
421 binary_path = os.path.join(cipd_bin_dir, 'cipd' + EXECUTABLE_SUFFIX)
nodirbe642ff2016-06-09 15:51:51 -0700422 if fs.isfile(binary_path):
nodir6dfdb2d2016-06-14 20:14:08 -0700423 file_path.remove(binary_path)
vadimsh232f5a82017-01-20 19:23:44 -0800424 else:
425 file_path.ensure_tree(cipd_bin_dir)
tansell9e04a8d2016-07-28 09:31:59 -0700426
427 with instance_cache.getfileobj(instance_id) as f:
428 isolateserver.putfile(f, binary_path, 0511) # -r-x--x--x
nodirbe642ff2016-06-09 15:51:51 -0700429
iannucci4d7792a2017-03-10 10:30:56 -0800430 _ensure_batfile(binary_path)
431
vadimsh232f5a82017-01-20 19:23:44 -0800432 yield CipdClient(
433 binary_path,
434 package_name=package_name,
435 instance_id=instance_id,
436 service_url=service_url)
nodir90bc8dc2016-06-15 13:35:21 -0700437
438
nodirff531b42016-06-23 13:05:06 -0700439def parse_package_args(packages):
440 """Parses --cipd-package arguments.
nodir90bc8dc2016-06-15 13:35:21 -0700441
nodirff531b42016-06-23 13:05:06 -0700442 Assumes |packages| were validated by validate_cipd_options.
443
444 Returns:
iannucci96fcccc2016-08-30 15:52:22 -0700445 A list of [(path, package_name, version), ...]
nodir90bc8dc2016-06-15 13:35:21 -0700446 """
iannucci96fcccc2016-08-30 15:52:22 -0700447 result = []
nodirff531b42016-06-23 13:05:06 -0700448 for pkg in packages:
449 path, name, version = pkg.split(':', 2)
nodir90bc8dc2016-06-15 13:35:21 -0700450 if not name:
nodirff531b42016-06-23 13:05:06 -0700451 raise Error('Invalid package "%s": package name is not specified' % pkg)
nodir90bc8dc2016-06-15 13:35:21 -0700452 if not version:
nodirff531b42016-06-23 13:05:06 -0700453 raise Error('Invalid package "%s": version is not specified' % pkg)
iannucci96fcccc2016-08-30 15:52:22 -0700454 result.append((path, name, version))
nodirff531b42016-06-23 13:05:06 -0700455 return result