blob: 36e8085514c025716b25a3c7f9e87a55556e3800 [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
32class Error(Exception):
33 """Raised on CIPD errors."""
34
35
36def add_cipd_options(parser):
37 group = optparse.OptionGroup(parser, 'CIPD')
38 group.add_option(
vadimsh902948e2017-01-20 15:57:32 -080039 '--cipd-enabled',
40 help='Enable CIPD client bootstrap. Implied by --cipd-package.',
41 action='store_true',
42 default=False)
43 group.add_option(
nodirbe642ff2016-06-09 15:51:51 -070044 '--cipd-server',
vadimsh902948e2017-01-20 15:57:32 -080045 help='URL of the CIPD server. '
46 'Only relevant with --cipd-enabled or --cipd-package.')
nodirbe642ff2016-06-09 15:51:51 -070047 group.add_option(
48 '--cipd-client-package',
nodir90bc8dc2016-06-15 13:35:21 -070049 help='Package name of CIPD client with optional parameters described in '
nodirff531b42016-06-23 13:05:06 -070050 '--cipd-package help. '
vadimsh902948e2017-01-20 15:57:32 -080051 'Only relevant with --cipd-enabled or --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070052 'Default: "%default"',
nodir90bc8dc2016-06-15 13:35:21 -070053 default='infra/tools/cipd/${platform}')
nodirbe642ff2016-06-09 15:51:51 -070054 group.add_option(
nodir90bc8dc2016-06-15 13:35:21 -070055 '--cipd-client-version',
56 help='Version of CIPD client. '
vadimsh902948e2017-01-20 15:57:32 -080057 'Only relevant with --cipd-enabled or --cipd-package. '
nodir90bc8dc2016-06-15 13:35:21 -070058 'Default: "%default"',
59 default='latest')
60 group.add_option(
nodirff531b42016-06-23 13:05:06 -070061 '--cipd-package',
62 dest='cipd_packages',
63 help='A CIPD package to install. '
64 'Format is "<path>:<package_name>:<version>". '
65 '"path" is installation directory relative to run_dir, '
66 'defaults to ".". '
nodir90bc8dc2016-06-15 13:35:21 -070067 '"package_name" may have ${platform} and/or ${os_ver} parameters. '
nodirbe642ff2016-06-09 15:51:51 -070068 '${platform} will be expanded to "<os>-<architecture>" and '
69 '${os_ver} will be expanded to OS version name. '
nodirff531b42016-06-23 13:05:06 -070070 'The option can be specified multiple times.',
71 action='append',
72 default=[])
nodirbe642ff2016-06-09 15:51:51 -070073 group.add_option(
74 '--cipd-cache',
75 help='CIPD cache directory, separate from isolate cache. '
vadimsh902948e2017-01-20 15:57:32 -080076 'Only relevant with --cipd-enabled or --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070077 'Default: "%default".',
78 default='')
79 parser.add_option_group(group)
80
81
82def validate_cipd_options(parser, options):
83 """Calls parser.error on first found error among cipd options."""
vadimsh902948e2017-01-20 15:57:32 -080084 if options.cipd_packages:
85 options.cipd_enabled = True
86
87 if not options.cipd_enabled:
nodirbe642ff2016-06-09 15:51:51 -070088 return
nodirff531b42016-06-23 13:05:06 -070089
90 for pkg in options.cipd_packages:
91 parts = pkg.split(':', 2)
92 if len(parts) != 3:
93 parser.error('invalid package "%s": must have at least 2 colons' % pkg)
94 _path, name, version = parts
95 if not name:
96 parser.error('invalid package "%s": package name is not specified' % pkg)
97 if not version:
98 parser.error('invalid package "%s": version is not specified' % pkg)
99
nodirbe642ff2016-06-09 15:51:51 -0700100 if not options.cipd_server:
vadimsh902948e2017-01-20 15:57:32 -0800101 parser.error('cipd is enabled, --cipd-server is required')
nodirbe642ff2016-06-09 15:51:51 -0700102
103 if not options.cipd_client_package:
nodirbe642ff2016-06-09 15:51:51 -0700104 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800105 'cipd is enabled, --cipd-client-package is required')
nodir90bc8dc2016-06-15 13:35:21 -0700106 if not options.cipd_client_version:
107 parser.error(
vadimsh902948e2017-01-20 15:57:32 -0800108 'cipd is enabled, --cipd-client-version is required')
nodirbe642ff2016-06-09 15:51:51 -0700109
110
111class CipdClient(object):
112 """Installs packages."""
113
iannucci96fcccc2016-08-30 15:52:22 -0700114 def __init__(self, binary_path, package_name, instance_id, service_url):
nodirbe642ff2016-06-09 15:51:51 -0700115 """Initializes CipdClient.
116
117 Args:
118 binary_path (str): path to the CIPD client binary.
iannucci96fcccc2016-08-30 15:52:22 -0700119 package_name (str): the CIPD package name for the client itself.
120 instance_id (str): the CIPD instance_id for the client itself.
nodirbe642ff2016-06-09 15:51:51 -0700121 service_url (str): if not None, URL of the CIPD backend that overrides
122 the default one.
123 """
124 self.binary_path = binary_path
iannucci96fcccc2016-08-30 15:52:22 -0700125 self.package_name = package_name
126 self.instance_id = instance_id
nodirbe642ff2016-06-09 15:51:51 -0700127 self.service_url = service_url
128
129 def ensure(
130 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None):
131 """Ensures that packages installed in |site_root| equals |packages| set.
132
133 Blocking call.
134
135 Args:
136 site_root (str): where to install packages.
nodir90bc8dc2016-06-15 13:35:21 -0700137 packages: list of (package_template, version) tuples.
nodirbe642ff2016-06-09 15:51:51 -0700138 cache_dir (str): if set, cache dir for cipd binary own cache.
139 Typically contains packages and tags.
140 tmp_dir (str): if not None, dir for temp files.
141 timeout (int): if not None, timeout in seconds for this function to run.
142
iannucci96fcccc2016-08-30 15:52:22 -0700143 Returns:
144 Pinned packages in the form of [(package_name, package_id)], which
145 correspond 1:1 with the input packages argument.
146
nodirbe642ff2016-06-09 15:51:51 -0700147 Raises:
148 Error if could not install packages or timed out.
149 """
150 timeoutfn = tools.sliding_timeout(timeout)
151 logging.info('Installing packages %r into %s', packages, site_root)
152
153 list_file_handle, list_file_path = tempfile.mkstemp(
154 dir=tmp_dir, prefix=u'cipd-ensure-list-', suffix='.txt')
iannucci96fcccc2016-08-30 15:52:22 -0700155 json_out_file_handle, json_file_path = tempfile.mkstemp(
156 dir=tmp_dir, prefix=u'cipd-ensure-result-', suffix='.json')
157 os.close(json_out_file_handle)
158
nodirbe642ff2016-06-09 15:51:51 -0700159 try:
160 try:
nodir90bc8dc2016-06-15 13:35:21 -0700161 for pkg, version in packages:
nodirbe642ff2016-06-09 15:51:51 -0700162 pkg = render_package_name_template(pkg)
163 os.write(list_file_handle, '%s %s\n' % (pkg, version))
164 finally:
165 os.close(list_file_handle)
166
167 cmd = [
168 self.binary_path, 'ensure',
169 '-root', site_root,
170 '-list', list_file_path,
171 '-verbose', # this is safe because cipd-ensure does not print a lot
iannucci96fcccc2016-08-30 15:52:22 -0700172 '-json-output', json_file_path,
nodirbe642ff2016-06-09 15:51:51 -0700173 ]
174 if cache_dir:
175 cmd += ['-cache-dir', cache_dir]
176 if self.service_url:
177 cmd += ['-service-url', self.service_url]
178
179 logging.debug('Running %r', cmd)
180 process = subprocess42.Popen(
181 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
182 output = []
183 for pipe_name, line in process.yield_any_line(timeout=0.1):
184 to = timeoutfn()
185 if to is not None and to <= 0:
186 raise Error(
187 'Could not install packages; took more than %d seconds' % timeout)
188 if not pipe_name:
189 # stdout or stderr was closed, but yield_any_line still may have
190 # something to yield.
191 continue
192 output.append(line)
193 if pipe_name == 'stderr':
194 logging.debug('cipd client: %s', line)
195 else:
196 logging.info('cipd client: %s', line)
197
198 exit_code = process.wait(timeout=timeoutfn())
199 if exit_code != 0:
200 raise Error(
201 'Could not install packages; exit code %d\noutput:%s' % (
202 exit_code, '\n'.join(output)))
iannucci96fcccc2016-08-30 15:52:22 -0700203 with open(json_file_path) as jfile:
204 result_json = json.load(jfile)
205 return [(x['package'], x['instance_id']) for x in result_json['result']]
nodirbe642ff2016-06-09 15:51:51 -0700206 finally:
207 fs.remove(list_file_path)
iannucci96fcccc2016-08-30 15:52:22 -0700208 fs.remove(json_file_path)
nodirbe642ff2016-06-09 15:51:51 -0700209
210
211def get_platform():
212 """Returns ${platform} parameter value.
213
214 Borrowed from
215 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
216 """
217 # linux, mac or windows.
218 platform_variant = {
219 'darwin': 'mac',
220 'linux2': 'linux',
221 'win32': 'windows',
222 }.get(sys.platform)
223 if not platform_variant:
224 raise Error('Unknown OS: %s' % sys.platform)
225
226 # amd64, 386, etc.
227 machine = platform.machine().lower()
228 platform_arch = {
229 'amd64': 'amd64',
230 'i386': '386',
231 'i686': '386',
232 'x86': '386',
233 'x86_64': 'amd64',
234 }.get(machine)
235 if not platform_arch:
236 if machine.startswith('arm'):
237 platform_arch = 'armv6l'
238 else:
239 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
240 return '%s-%s' % (platform_variant, platform_arch)
241
242
243def get_os_ver():
244 """Returns ${os_ver} parameter value.
245
246 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
247
248 Borrowed from
249 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
250 """
251 if sys.platform == 'darwin':
252 # platform.mac_ver()[0] is '10.9.5'.
253 dist = platform.mac_ver()[0].split('.')
254 return 'mac%s_%s' % (dist[0], dist[1])
255
256 if sys.platform == 'linux2':
257 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
258 dist = platform.linux_distribution()
259 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
260
261 if sys.platform == 'win32':
262 # platform.version() is '6.1.7601'.
263 dist = platform.version().split('.')
264 return 'win%s_%s' % (dist[0], dist[1])
265 raise Error('Unknown OS: %s' % sys.platform)
266
267
268def render_package_name_template(template):
269 """Expands template variables in a CIPD package name template."""
270 return (template
271 .lower() # Package names are always lower case
272 .replace('${platform}', get_platform())
273 .replace('${os_ver}', get_os_ver()))
274
275
nodirbe642ff2016-06-09 15:51:51 -0700276def _check_response(res, fmt, *args):
277 """Raises Error if response is bad."""
278 if not res:
279 raise Error('%s: no response' % (fmt % args))
280
281 if res.get('status') != 'SUCCESS':
282 raise Error('%s: %s' % (
283 fmt % args,
284 res.get('error_message') or 'status is %s' % res.get('status')))
285
286
287def resolve_version(cipd_server, package_name, version, timeout=None):
288 """Resolves a package instance version (e.g. a tag) to an instance id."""
289 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
290 cipd_server,
291 urllib.urlencode({
292 'package_name': package_name,
293 'version': version,
294 }))
295 res = net.url_read_json(url, timeout=timeout)
296 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
297 instance_id = res.get('instance_id')
298 if not instance_id:
299 raise Error('Invalid resolveVersion response: no instance id')
300 return instance_id
301
302
303def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
304 """Returns a fetch URL of CIPD client binary contents.
305
306 Raises:
307 Error if cannot retrieve fetch URL.
308 """
309 # Fetch the URL of the binary from CIPD backend.
310 package_name = render_package_name_template(package_name)
311 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
312 'package_name': package_name,
313 'instance_id': instance_id,
314 }))
315 res = net.url_read_json(url, timeout=timeout)
316 _check_response(
317 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
318 fetch_url = res.get('client_binary', {}).get('fetch_url')
319 if not fetch_url:
320 raise Error('Invalid fetchClientBinary response: no fetch_url')
321 return fetch_url
322
323
324def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
325 """Fetches cipd binary to |disk_cache|.
326
327 Retries requests with exponential back-off.
328
329 Raises:
330 Error if could not fetch content.
331 """
332 sleep_time = 1
333 for attempt in xrange(5):
334 if attempt > 0:
335 if timeoutfn() is not None and timeoutfn() < sleep_time:
336 raise Error('Could not fetch CIPD client: timeout')
337 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
338 time.sleep(sleep_time)
339 sleep_time *= 2
340
341 try:
342 res = net.url_open(fetch_url, timeout=timeoutfn())
343 if res:
344 disk_cache.write(instance_id, res.iter_content(64 * 1024))
345 return
346 except net.TimeoutError as ex:
347 raise Error('Could not fetch CIPD client: %s', ex)
348 except net.NetError as ex:
349 logging.warning(
350 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
351
352 raise Error('Could not fetch CIPD client after 5 retries')
353
354
355@contextlib.contextmanager
356def get_client(
357 service_url, package_name, version, cache_dir, timeout=None):
358 """Returns a context manager that yields a CipdClient. A blocking call.
359
360 Args:
361 service_url (str): URL of the CIPD backend.
362 package_name (str): package name template of the CIPD client.
363 version (str): version of CIPD client package.
364 cache_dir: directory to store instance cache, version cache
365 and a hardlink to the client binary.
366 timeout (int): if not None, timeout in seconds for this function.
367
368 Yields:
369 CipdClient.
370
371 Raises:
372 Error if CIPD client version cannot be resolved or client cannot be fetched.
373 """
374 timeoutfn = tools.sliding_timeout(timeout)
375
376 package_name = render_package_name_template(package_name)
377
378 # Resolve version to instance id.
379 # Is it an instance id already? They look like HEX SHA1.
380 if isolated_format.is_valid_hash(version, hashlib.sha1):
381 instance_id = version
iannucci6fd57d22016-08-30 17:02:20 -0700382 elif ':' in version: # it's an immutable tag
nodirbe642ff2016-06-09 15:51:51 -0700383 # version_cache is {version_digest -> instance id} mapping.
384 # It does not take a lot of disk space.
385 version_cache = isolateserver.DiskCache(
386 unicode(os.path.join(cache_dir, 'versions')),
387 isolateserver.CachePolicies(0, 0, 300),
388 hashlib.sha1)
389 with version_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700390 version_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700391 # Convert |version| to a string that may be used as a filename in disk
392 # cache by hashing it.
393 version_digest = hashlib.sha1(version).hexdigest()
394 try:
tansell9e04a8d2016-07-28 09:31:59 -0700395 with version_cache.getfileobj(version_digest) as f:
396 instance_id = f.read()
nodirbe642ff2016-06-09 15:51:51 -0700397 except isolateserver.CacheMiss:
398 instance_id = resolve_version(
399 service_url, package_name, version, timeout=timeoutfn())
400 version_cache.write(version_digest, instance_id)
iannucci6fd57d22016-08-30 17:02:20 -0700401 else: # it's a ref
402 instance_id = resolve_version(
403 service_url, package_name, version, timeout=timeoutfn())
nodirbe642ff2016-06-09 15:51:51 -0700404
405 # instance_cache is {instance_id -> client binary} mapping.
406 # It is bounded by 5 client versions.
407 instance_cache = isolateserver.DiskCache(
408 unicode(os.path.join(cache_dir, 'clients')),
409 isolateserver.CachePolicies(0, 0, 5),
410 hashlib.sha1)
411 with instance_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700412 instance_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700413 if instance_id not in instance_cache:
414 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
415 fetch_url = get_client_fetch_url(
416 service_url, package_name, instance_id, timeout=timeoutfn())
417 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
418
419 # A single host can run multiple swarming bots, but ATM they do not share
420 # same root bot directory. Thus, it is safe to use the same name for the
421 # binary.
422 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
423 if fs.isfile(binary_path):
nodir6dfdb2d2016-06-14 20:14:08 -0700424 file_path.remove(binary_path)
tansell9e04a8d2016-07-28 09:31:59 -0700425
426 with instance_cache.getfileobj(instance_id) as f:
427 isolateserver.putfile(f, binary_path, 0511) # -r-x--x--x
nodirbe642ff2016-06-09 15:51:51 -0700428
iannucci96fcccc2016-08-30 15:52:22 -0700429 yield CipdClient(binary_path, package_name=package_name,
430 instance_id=instance_id, service_url=service_url)
nodir90bc8dc2016-06-15 13:35:21 -0700431
432
nodirff531b42016-06-23 13:05:06 -0700433def parse_package_args(packages):
434 """Parses --cipd-package arguments.
nodir90bc8dc2016-06-15 13:35:21 -0700435
nodirff531b42016-06-23 13:05:06 -0700436 Assumes |packages| were validated by validate_cipd_options.
437
438 Returns:
iannucci96fcccc2016-08-30 15:52:22 -0700439 A list of [(path, package_name, version), ...]
nodir90bc8dc2016-06-15 13:35:21 -0700440 """
iannucci96fcccc2016-08-30 15:52:22 -0700441 result = []
nodirff531b42016-06-23 13:05:06 -0700442 for pkg in packages:
443 path, name, version = pkg.split(':', 2)
nodir90bc8dc2016-06-15 13:35:21 -0700444 if not name:
nodirff531b42016-06-23 13:05:06 -0700445 raise Error('Invalid package "%s": package name is not specified' % pkg)
nodir90bc8dc2016-06-15 13:35:21 -0700446 if not version:
nodirff531b42016-06-23 13:05:06 -0700447 raise Error('Invalid package "%s": version is not specified' % pkg)
iannucci96fcccc2016-08-30 15:52:22 -0700448 result.append((path, name, version))
nodirff531b42016-06-23 13:05:06 -0700449 return result