blob: 5f329638762b03c92938f0eee597c7c6f27be478 [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
iannucci96fcccc2016-08-30 15:52:22 -07007__version__ = '0.3'
nodirbe642ff2016-06-09 15:51:51 -07008
nodir90bc8dc2016-06-15 13:35:21 -07009import collections
nodirbe642ff2016-06-09 15:51:51 -070010import contextlib
11import hashlib
iannucci96fcccc2016-08-30 15:52:22 -070012import json
nodirbe642ff2016-06-09 15:51:51 -070013import logging
14import optparse
15import os
16import platform
17import sys
18import tempfile
19import time
20import urllib
21
22from utils import file_path
23from utils import fs
24from utils import net
25from utils import subprocess42
26from utils import tools
27import isolated_format
28import isolateserver
29
30
31# .exe on Windows.
32EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
33
34
35class Error(Exception):
36 """Raised on CIPD errors."""
37
38
39def add_cipd_options(parser):
40 group = optparse.OptionGroup(parser, 'CIPD')
41 group.add_option(
42 '--cipd-server',
nodirff531b42016-06-23 13:05:06 -070043 help='URL of the CIPD server. Only relevant with --cipd-package.')
nodirbe642ff2016-06-09 15:51:51 -070044 group.add_option(
45 '--cipd-client-package',
nodir90bc8dc2016-06-15 13:35:21 -070046 help='Package name of CIPD client with optional parameters described in '
nodirff531b42016-06-23 13:05:06 -070047 '--cipd-package help. '
48 'Only relevant with --cipd-package. '
nodirbe642ff2016-06-09 15:51:51 -070049 'Default: "%default"',
nodir90bc8dc2016-06-15 13:35:21 -070050 default='infra/tools/cipd/${platform}')
nodirbe642ff2016-06-09 15:51:51 -070051 group.add_option(
nodir90bc8dc2016-06-15 13:35:21 -070052 '--cipd-client-version',
53 help='Version of CIPD client. '
nodirff531b42016-06-23 13:05:06 -070054 'Only relevant with --cipd-package. '
nodir90bc8dc2016-06-15 13:35:21 -070055 'Default: "%default"',
56 default='latest')
57 group.add_option(
nodirff531b42016-06-23 13:05:06 -070058 '--cipd-package',
59 dest='cipd_packages',
60 help='A CIPD package to install. '
61 'Format is "<path>:<package_name>:<version>". '
62 '"path" is installation directory relative to run_dir, '
63 'defaults to ".". '
nodir90bc8dc2016-06-15 13:35:21 -070064 '"package_name" may have ${platform} and/or ${os_ver} parameters. '
nodirbe642ff2016-06-09 15:51:51 -070065 '${platform} will be expanded to "<os>-<architecture>" and '
66 '${os_ver} will be expanded to OS version name. '
nodirff531b42016-06-23 13:05:06 -070067 'The option can be specified multiple times.',
68 action='append',
69 default=[])
nodirbe642ff2016-06-09 15:51:51 -070070 group.add_option(
71 '--cipd-cache',
72 help='CIPD cache directory, separate from isolate cache. '
73 'Only relevant with --cipd-package. '
74 'Default: "%default".',
75 default='')
76 parser.add_option_group(group)
77
78
79def validate_cipd_options(parser, options):
80 """Calls parser.error on first found error among cipd options."""
nodirff531b42016-06-23 13:05:06 -070081 if not options.cipd_packages:
nodirbe642ff2016-06-09 15:51:51 -070082 return
nodirff531b42016-06-23 13:05:06 -070083
84 for pkg in options.cipd_packages:
85 parts = pkg.split(':', 2)
86 if len(parts) != 3:
87 parser.error('invalid package "%s": must have at least 2 colons' % pkg)
88 _path, name, version = parts
89 if not name:
90 parser.error('invalid package "%s": package name is not specified' % pkg)
91 if not version:
92 parser.error('invalid package "%s": version is not specified' % pkg)
93
nodirbe642ff2016-06-09 15:51:51 -070094 if not options.cipd_server:
nodirff531b42016-06-23 13:05:06 -070095 parser.error('--cipd-package requires non-empty --cipd-server')
nodirbe642ff2016-06-09 15:51:51 -070096
97 if not options.cipd_client_package:
nodirbe642ff2016-06-09 15:51:51 -070098 parser.error(
nodirff531b42016-06-23 13:05:06 -070099 '--cipd-package requires non-empty --cipd-client-package')
nodir90bc8dc2016-06-15 13:35:21 -0700100 if not options.cipd_client_version:
101 parser.error(
nodirff531b42016-06-23 13:05:06 -0700102 '--cipd-package requires non-empty --cipd-client-version')
nodirbe642ff2016-06-09 15:51:51 -0700103
104
105class CipdClient(object):
106 """Installs packages."""
107
iannucci96fcccc2016-08-30 15:52:22 -0700108 def __init__(self, binary_path, package_name, instance_id, service_url):
nodirbe642ff2016-06-09 15:51:51 -0700109 """Initializes CipdClient.
110
111 Args:
112 binary_path (str): path to the CIPD client binary.
iannucci96fcccc2016-08-30 15:52:22 -0700113 package_name (str): the CIPD package name for the client itself.
114 instance_id (str): the CIPD instance_id for the client itself.
nodirbe642ff2016-06-09 15:51:51 -0700115 service_url (str): if not None, URL of the CIPD backend that overrides
116 the default one.
117 """
118 self.binary_path = binary_path
iannucci96fcccc2016-08-30 15:52:22 -0700119 self.package_name = package_name
120 self.instance_id = instance_id
nodirbe642ff2016-06-09 15:51:51 -0700121 self.service_url = service_url
122
123 def ensure(
124 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None):
125 """Ensures that packages installed in |site_root| equals |packages| set.
126
127 Blocking call.
128
129 Args:
130 site_root (str): where to install packages.
nodir90bc8dc2016-06-15 13:35:21 -0700131 packages: list of (package_template, version) tuples.
nodirbe642ff2016-06-09 15:51:51 -0700132 cache_dir (str): if set, cache dir for cipd binary own cache.
133 Typically contains packages and tags.
134 tmp_dir (str): if not None, dir for temp files.
135 timeout (int): if not None, timeout in seconds for this function to run.
136
iannucci96fcccc2016-08-30 15:52:22 -0700137 Returns:
138 Pinned packages in the form of [(package_name, package_id)], which
139 correspond 1:1 with the input packages argument.
140
nodirbe642ff2016-06-09 15:51:51 -0700141 Raises:
142 Error if could not install packages or timed out.
143 """
144 timeoutfn = tools.sliding_timeout(timeout)
145 logging.info('Installing packages %r into %s', packages, site_root)
146
147 list_file_handle, list_file_path = tempfile.mkstemp(
148 dir=tmp_dir, prefix=u'cipd-ensure-list-', suffix='.txt')
iannucci96fcccc2016-08-30 15:52:22 -0700149 json_out_file_handle, json_file_path = tempfile.mkstemp(
150 dir=tmp_dir, prefix=u'cipd-ensure-result-', suffix='.json')
151 os.close(json_out_file_handle)
152
nodirbe642ff2016-06-09 15:51:51 -0700153 try:
154 try:
nodir90bc8dc2016-06-15 13:35:21 -0700155 for pkg, version in packages:
nodirbe642ff2016-06-09 15:51:51 -0700156 pkg = render_package_name_template(pkg)
157 os.write(list_file_handle, '%s %s\n' % (pkg, version))
158 finally:
159 os.close(list_file_handle)
160
161 cmd = [
162 self.binary_path, 'ensure',
163 '-root', site_root,
164 '-list', list_file_path,
165 '-verbose', # this is safe because cipd-ensure does not print a lot
iannucci96fcccc2016-08-30 15:52:22 -0700166 '-json-output', json_file_path,
nodirbe642ff2016-06-09 15:51:51 -0700167 ]
168 if cache_dir:
169 cmd += ['-cache-dir', cache_dir]
170 if self.service_url:
171 cmd += ['-service-url', self.service_url]
172
173 logging.debug('Running %r', cmd)
174 process = subprocess42.Popen(
175 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
176 output = []
177 for pipe_name, line in process.yield_any_line(timeout=0.1):
178 to = timeoutfn()
179 if to is not None and to <= 0:
180 raise Error(
181 'Could not install packages; took more than %d seconds' % timeout)
182 if not pipe_name:
183 # stdout or stderr was closed, but yield_any_line still may have
184 # something to yield.
185 continue
186 output.append(line)
187 if pipe_name == 'stderr':
188 logging.debug('cipd client: %s', line)
189 else:
190 logging.info('cipd client: %s', line)
191
192 exit_code = process.wait(timeout=timeoutfn())
193 if exit_code != 0:
194 raise Error(
195 'Could not install packages; exit code %d\noutput:%s' % (
196 exit_code, '\n'.join(output)))
iannucci96fcccc2016-08-30 15:52:22 -0700197 with open(json_file_path) as jfile:
198 result_json = json.load(jfile)
199 return [(x['package'], x['instance_id']) for x in result_json['result']]
nodirbe642ff2016-06-09 15:51:51 -0700200 finally:
201 fs.remove(list_file_path)
iannucci96fcccc2016-08-30 15:52:22 -0700202 fs.remove(json_file_path)
nodirbe642ff2016-06-09 15:51:51 -0700203
204
205def get_platform():
206 """Returns ${platform} parameter value.
207
208 Borrowed from
209 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
210 """
211 # linux, mac or windows.
212 platform_variant = {
213 'darwin': 'mac',
214 'linux2': 'linux',
215 'win32': 'windows',
216 }.get(sys.platform)
217 if not platform_variant:
218 raise Error('Unknown OS: %s' % sys.platform)
219
220 # amd64, 386, etc.
221 machine = platform.machine().lower()
222 platform_arch = {
223 'amd64': 'amd64',
224 'i386': '386',
225 'i686': '386',
226 'x86': '386',
227 'x86_64': 'amd64',
228 }.get(machine)
229 if not platform_arch:
230 if machine.startswith('arm'):
231 platform_arch = 'armv6l'
232 else:
233 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
234 return '%s-%s' % (platform_variant, platform_arch)
235
236
237def get_os_ver():
238 """Returns ${os_ver} parameter value.
239
240 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
241
242 Borrowed from
243 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
244 """
245 if sys.platform == 'darwin':
246 # platform.mac_ver()[0] is '10.9.5'.
247 dist = platform.mac_ver()[0].split('.')
248 return 'mac%s_%s' % (dist[0], dist[1])
249
250 if sys.platform == 'linux2':
251 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
252 dist = platform.linux_distribution()
253 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
254
255 if sys.platform == 'win32':
256 # platform.version() is '6.1.7601'.
257 dist = platform.version().split('.')
258 return 'win%s_%s' % (dist[0], dist[1])
259 raise Error('Unknown OS: %s' % sys.platform)
260
261
262def render_package_name_template(template):
263 """Expands template variables in a CIPD package name template."""
264 return (template
265 .lower() # Package names are always lower case
266 .replace('${platform}', get_platform())
267 .replace('${os_ver}', get_os_ver()))
268
269
nodirbe642ff2016-06-09 15:51:51 -0700270def _check_response(res, fmt, *args):
271 """Raises Error if response is bad."""
272 if not res:
273 raise Error('%s: no response' % (fmt % args))
274
275 if res.get('status') != 'SUCCESS':
276 raise Error('%s: %s' % (
277 fmt % args,
278 res.get('error_message') or 'status is %s' % res.get('status')))
279
280
281def resolve_version(cipd_server, package_name, version, timeout=None):
282 """Resolves a package instance version (e.g. a tag) to an instance id."""
283 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
284 cipd_server,
285 urllib.urlencode({
286 'package_name': package_name,
287 'version': version,
288 }))
289 res = net.url_read_json(url, timeout=timeout)
290 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
291 instance_id = res.get('instance_id')
292 if not instance_id:
293 raise Error('Invalid resolveVersion response: no instance id')
294 return instance_id
295
296
297def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
298 """Returns a fetch URL of CIPD client binary contents.
299
300 Raises:
301 Error if cannot retrieve fetch URL.
302 """
303 # Fetch the URL of the binary from CIPD backend.
304 package_name = render_package_name_template(package_name)
305 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
306 'package_name': package_name,
307 'instance_id': instance_id,
308 }))
309 res = net.url_read_json(url, timeout=timeout)
310 _check_response(
311 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
312 fetch_url = res.get('client_binary', {}).get('fetch_url')
313 if not fetch_url:
314 raise Error('Invalid fetchClientBinary response: no fetch_url')
315 return fetch_url
316
317
318def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
319 """Fetches cipd binary to |disk_cache|.
320
321 Retries requests with exponential back-off.
322
323 Raises:
324 Error if could not fetch content.
325 """
326 sleep_time = 1
327 for attempt in xrange(5):
328 if attempt > 0:
329 if timeoutfn() is not None and timeoutfn() < sleep_time:
330 raise Error('Could not fetch CIPD client: timeout')
331 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
332 time.sleep(sleep_time)
333 sleep_time *= 2
334
335 try:
336 res = net.url_open(fetch_url, timeout=timeoutfn())
337 if res:
338 disk_cache.write(instance_id, res.iter_content(64 * 1024))
339 return
340 except net.TimeoutError as ex:
341 raise Error('Could not fetch CIPD client: %s', ex)
342 except net.NetError as ex:
343 logging.warning(
344 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
345
346 raise Error('Could not fetch CIPD client after 5 retries')
347
348
349@contextlib.contextmanager
350def get_client(
351 service_url, package_name, version, cache_dir, timeout=None):
352 """Returns a context manager that yields a CipdClient. A blocking call.
353
354 Args:
355 service_url (str): URL of the CIPD backend.
356 package_name (str): package name template of the CIPD client.
357 version (str): version of CIPD client package.
358 cache_dir: directory to store instance cache, version cache
359 and a hardlink to the client binary.
360 timeout (int): if not None, timeout in seconds for this function.
361
362 Yields:
363 CipdClient.
364
365 Raises:
366 Error if CIPD client version cannot be resolved or client cannot be fetched.
367 """
368 timeoutfn = tools.sliding_timeout(timeout)
369
370 package_name = render_package_name_template(package_name)
371
372 # Resolve version to instance id.
373 # Is it an instance id already? They look like HEX SHA1.
374 if isolated_format.is_valid_hash(version, hashlib.sha1):
375 instance_id = version
376 else:
377 # version_cache is {version_digest -> instance id} mapping.
378 # It does not take a lot of disk space.
379 version_cache = isolateserver.DiskCache(
380 unicode(os.path.join(cache_dir, 'versions')),
381 isolateserver.CachePolicies(0, 0, 300),
382 hashlib.sha1)
383 with version_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700384 version_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700385 # Convert |version| to a string that may be used as a filename in disk
386 # cache by hashing it.
387 version_digest = hashlib.sha1(version).hexdigest()
388 try:
tansell9e04a8d2016-07-28 09:31:59 -0700389 with version_cache.getfileobj(version_digest) as f:
390 instance_id = f.read()
nodirbe642ff2016-06-09 15:51:51 -0700391 except isolateserver.CacheMiss:
392 instance_id = resolve_version(
393 service_url, package_name, version, timeout=timeoutfn())
394 version_cache.write(version_digest, instance_id)
395
396 # instance_cache is {instance_id -> client binary} mapping.
397 # It is bounded by 5 client versions.
398 instance_cache = isolateserver.DiskCache(
399 unicode(os.path.join(cache_dir, 'clients')),
400 isolateserver.CachePolicies(0, 0, 5),
401 hashlib.sha1)
402 with instance_cache:
maruel2e8d0f52016-07-16 07:51:29 -0700403 instance_cache.cleanup()
nodirbe642ff2016-06-09 15:51:51 -0700404 if instance_id not in instance_cache:
405 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
406 fetch_url = get_client_fetch_url(
407 service_url, package_name, instance_id, timeout=timeoutfn())
408 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
409
410 # A single host can run multiple swarming bots, but ATM they do not share
411 # same root bot directory. Thus, it is safe to use the same name for the
412 # binary.
413 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
414 if fs.isfile(binary_path):
nodir6dfdb2d2016-06-14 20:14:08 -0700415 file_path.remove(binary_path)
tansell9e04a8d2016-07-28 09:31:59 -0700416
417 with instance_cache.getfileobj(instance_id) as f:
418 isolateserver.putfile(f, binary_path, 0511) # -r-x--x--x
nodirbe642ff2016-06-09 15:51:51 -0700419
iannucci96fcccc2016-08-30 15:52:22 -0700420 yield CipdClient(binary_path, package_name=package_name,
421 instance_id=instance_id, service_url=service_url)
nodir90bc8dc2016-06-15 13:35:21 -0700422
423
nodirff531b42016-06-23 13:05:06 -0700424def parse_package_args(packages):
425 """Parses --cipd-package arguments.
nodir90bc8dc2016-06-15 13:35:21 -0700426
nodirff531b42016-06-23 13:05:06 -0700427 Assumes |packages| were validated by validate_cipd_options.
428
429 Returns:
iannucci96fcccc2016-08-30 15:52:22 -0700430 A list of [(path, package_name, version), ...]
nodir90bc8dc2016-06-15 13:35:21 -0700431 """
iannucci96fcccc2016-08-30 15:52:22 -0700432 result = []
nodirff531b42016-06-23 13:05:06 -0700433 for pkg in packages:
434 path, name, version = pkg.split(':', 2)
nodir90bc8dc2016-06-15 13:35:21 -0700435 if not name:
nodirff531b42016-06-23 13:05:06 -0700436 raise Error('Invalid package "%s": package name is not specified' % pkg)
nodir90bc8dc2016-06-15 13:35:21 -0700437 if not version:
nodirff531b42016-06-23 13:05:06 -0700438 raise Error('Invalid package "%s": version is not specified' % pkg)
iannucci96fcccc2016-08-30 15:52:22 -0700439 result.append((path, name, version))
nodirff531b42016-06-23 13:05:06 -0700440 return result