blob: b4e6b80377714f15dfc97603d7b43eec06aaef65 [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
7__version__ = '0.1'
8
9import contextlib
10import hashlib
11import logging
12import optparse
13import os
14import platform
15import sys
16import tempfile
17import time
18import urllib
19
20from utils import file_path
21from utils import fs
22from utils import net
23from utils import subprocess42
24from utils import tools
25import isolated_format
26import isolateserver
27
28
29# .exe on Windows.
30EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
31
32
33class Error(Exception):
34 """Raised on CIPD errors."""
35
36
37def add_cipd_options(parser):
38 group = optparse.OptionGroup(parser, 'CIPD')
39 group.add_option(
40 '--cipd-server',
41 help='URL of the CIPD server. Only relevant with --cipd-package.')
42 group.add_option(
43 '--cipd-client-package',
44 help='Package of CIPD client. See --cipd-package for format. '
45 'Only relevant with --cipd-package. '
46 'Default: "%default"',
47 default='infra/tools/cipd/${platform}:latest')
48 group.add_option(
49 '--cipd-package',
50 help='CIPD package to install. '
51 'Format: "<package name template>:<version>". '
52 'Package name template is a CIPD package name with optional '
53 '${platform} and/or ${os_ver} parameters. '
54 '${platform} will be expanded to "<os>-<architecture>" and '
55 '${os_ver} will be expanded to OS version name. '
56 'This option can be specified more than once.',
57 action='append')
58 group.add_option(
59 '--cipd-cache',
60 help='CIPD cache directory, separate from isolate cache. '
61 'Only relevant with --cipd-package. '
62 'Default: "%default".',
63 default='')
64 parser.add_option_group(group)
65
66
67def validate_cipd_options(parser, options):
68 """Calls parser.error on first found error among cipd options."""
69 if not options.cipd_package:
70 return
71 for p in options.cipd_package:
72 try:
73 parse_package(p)
74 except ValueError as ex:
75 parser.error('Invalid cipd package %r: %s' % (p, ex))
76
77 if not options.cipd_server:
78 parser.error('--cipd-package requires non-empty --cipd-server')
79
80 if not options.cipd_client_package:
81 parser.error('--cipd-package requires non-empty --cipd-client-package')
82 try:
83 parse_package(options.cipd_client_package)
84 except ValueError as ex:
85 parser.error(
86 'Invalid cipd client package %r: %s' %
87 (options.cipd_client_package, ex))
88
89
90class CipdClient(object):
91 """Installs packages."""
92
93 def __init__(self, binary_path, service_url=None):
94 """Initializes CipdClient.
95
96 Args:
97 binary_path (str): path to the CIPD client binary.
98 service_url (str): if not None, URL of the CIPD backend that overrides
99 the default one.
100 """
101 self.binary_path = binary_path
102 self.service_url = service_url
103
104 def ensure(
105 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None):
106 """Ensures that packages installed in |site_root| equals |packages| set.
107
108 Blocking call.
109
110 Args:
111 site_root (str): where to install packages.
112 packages (str): list of packages to install, parsable by parse_pacakge().
113 cache_dir (str): if set, cache dir for cipd binary own cache.
114 Typically contains packages and tags.
115 tmp_dir (str): if not None, dir for temp files.
116 timeout (int): if not None, timeout in seconds for this function to run.
117
118 Raises:
119 Error if could not install packages or timed out.
120 """
121 timeoutfn = tools.sliding_timeout(timeout)
122 logging.info('Installing packages %r into %s', packages, site_root)
123
124 list_file_handle, list_file_path = tempfile.mkstemp(
125 dir=tmp_dir, prefix=u'cipd-ensure-list-', suffix='.txt')
126 try:
127 try:
128 for p in packages:
129 pkg, version = parse_package(p)
130 pkg = render_package_name_template(pkg)
131 os.write(list_file_handle, '%s %s\n' % (pkg, version))
132 finally:
133 os.close(list_file_handle)
134
135 cmd = [
136 self.binary_path, 'ensure',
137 '-root', site_root,
138 '-list', list_file_path,
139 '-verbose', # this is safe because cipd-ensure does not print a lot
140 ]
141 if cache_dir:
142 cmd += ['-cache-dir', cache_dir]
143 if self.service_url:
144 cmd += ['-service-url', self.service_url]
145
146 logging.debug('Running %r', cmd)
147 process = subprocess42.Popen(
148 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
149 output = []
150 for pipe_name, line in process.yield_any_line(timeout=0.1):
151 to = timeoutfn()
152 if to is not None and to <= 0:
153 raise Error(
154 'Could not install packages; took more than %d seconds' % timeout)
155 if not pipe_name:
156 # stdout or stderr was closed, but yield_any_line still may have
157 # something to yield.
158 continue
159 output.append(line)
160 if pipe_name == 'stderr':
161 logging.debug('cipd client: %s', line)
162 else:
163 logging.info('cipd client: %s', line)
164
165 exit_code = process.wait(timeout=timeoutfn())
166 if exit_code != 0:
167 raise Error(
168 'Could not install packages; exit code %d\noutput:%s' % (
169 exit_code, '\n'.join(output)))
170 finally:
171 fs.remove(list_file_path)
172
173
174def get_platform():
175 """Returns ${platform} parameter value.
176
177 Borrowed from
178 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
179 """
180 # linux, mac or windows.
181 platform_variant = {
182 'darwin': 'mac',
183 'linux2': 'linux',
184 'win32': 'windows',
185 }.get(sys.platform)
186 if not platform_variant:
187 raise Error('Unknown OS: %s' % sys.platform)
188
189 # amd64, 386, etc.
190 machine = platform.machine().lower()
191 platform_arch = {
192 'amd64': 'amd64',
193 'i386': '386',
194 'i686': '386',
195 'x86': '386',
196 'x86_64': 'amd64',
197 }.get(machine)
198 if not platform_arch:
199 if machine.startswith('arm'):
200 platform_arch = 'armv6l'
201 else:
202 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
203 return '%s-%s' % (platform_variant, platform_arch)
204
205
206def get_os_ver():
207 """Returns ${os_ver} parameter value.
208
209 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
210
211 Borrowed from
212 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
213 """
214 if sys.platform == 'darwin':
215 # platform.mac_ver()[0] is '10.9.5'.
216 dist = platform.mac_ver()[0].split('.')
217 return 'mac%s_%s' % (dist[0], dist[1])
218
219 if sys.platform == 'linux2':
220 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
221 dist = platform.linux_distribution()
222 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
223
224 if sys.platform == 'win32':
225 # platform.version() is '6.1.7601'.
226 dist = platform.version().split('.')
227 return 'win%s_%s' % (dist[0], dist[1])
228 raise Error('Unknown OS: %s' % sys.platform)
229
230
231def render_package_name_template(template):
232 """Expands template variables in a CIPD package name template."""
233 return (template
234 .lower() # Package names are always lower case
235 .replace('${platform}', get_platform())
236 .replace('${os_ver}', get_os_ver()))
237
238
239def parse_package(package):
240 """Parses a package in --cipd-package format.
241
242 Returns:
243 (package_name_template, version) tuple.
244
245 Raises:
246 ValueError if package name or version is not specified.
247 """
248 if not package:
249 raise ValueError('package is not specified')
250 parts = package.split(':', 1)
251 if len(parts) != 2:
252 raise ValueError('version is not specified')
253 return tuple(parts)
254
255
256def _check_response(res, fmt, *args):
257 """Raises Error if response is bad."""
258 if not res:
259 raise Error('%s: no response' % (fmt % args))
260
261 if res.get('status') != 'SUCCESS':
262 raise Error('%s: %s' % (
263 fmt % args,
264 res.get('error_message') or 'status is %s' % res.get('status')))
265
266
267def resolve_version(cipd_server, package_name, version, timeout=None):
268 """Resolves a package instance version (e.g. a tag) to an instance id."""
269 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
270 cipd_server,
271 urllib.urlencode({
272 'package_name': package_name,
273 'version': version,
274 }))
275 res = net.url_read_json(url, timeout=timeout)
276 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
277 instance_id = res.get('instance_id')
278 if not instance_id:
279 raise Error('Invalid resolveVersion response: no instance id')
280 return instance_id
281
282
283def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
284 """Returns a fetch URL of CIPD client binary contents.
285
286 Raises:
287 Error if cannot retrieve fetch URL.
288 """
289 # Fetch the URL of the binary from CIPD backend.
290 package_name = render_package_name_template(package_name)
291 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
292 'package_name': package_name,
293 'instance_id': instance_id,
294 }))
295 res = net.url_read_json(url, timeout=timeout)
296 _check_response(
297 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
298 fetch_url = res.get('client_binary', {}).get('fetch_url')
299 if not fetch_url:
300 raise Error('Invalid fetchClientBinary response: no fetch_url')
301 return fetch_url
302
303
304def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
305 """Fetches cipd binary to |disk_cache|.
306
307 Retries requests with exponential back-off.
308
309 Raises:
310 Error if could not fetch content.
311 """
312 sleep_time = 1
313 for attempt in xrange(5):
314 if attempt > 0:
315 if timeoutfn() is not None and timeoutfn() < sleep_time:
316 raise Error('Could not fetch CIPD client: timeout')
317 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
318 time.sleep(sleep_time)
319 sleep_time *= 2
320
321 try:
322 res = net.url_open(fetch_url, timeout=timeoutfn())
323 if res:
324 disk_cache.write(instance_id, res.iter_content(64 * 1024))
325 return
326 except net.TimeoutError as ex:
327 raise Error('Could not fetch CIPD client: %s', ex)
328 except net.NetError as ex:
329 logging.warning(
330 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
331
332 raise Error('Could not fetch CIPD client after 5 retries')
333
334
335@contextlib.contextmanager
336def get_client(
337 service_url, package_name, version, cache_dir, timeout=None):
338 """Returns a context manager that yields a CipdClient. A blocking call.
339
340 Args:
341 service_url (str): URL of the CIPD backend.
342 package_name (str): package name template of the CIPD client.
343 version (str): version of CIPD client package.
344 cache_dir: directory to store instance cache, version cache
345 and a hardlink to the client binary.
346 timeout (int): if not None, timeout in seconds for this function.
347
348 Yields:
349 CipdClient.
350
351 Raises:
352 Error if CIPD client version cannot be resolved or client cannot be fetched.
353 """
354 timeoutfn = tools.sliding_timeout(timeout)
355
356 package_name = render_package_name_template(package_name)
357
358 # Resolve version to instance id.
359 # Is it an instance id already? They look like HEX SHA1.
360 if isolated_format.is_valid_hash(version, hashlib.sha1):
361 instance_id = version
362 else:
363 # version_cache is {version_digest -> instance id} mapping.
364 # It does not take a lot of disk space.
365 version_cache = isolateserver.DiskCache(
366 unicode(os.path.join(cache_dir, 'versions')),
367 isolateserver.CachePolicies(0, 0, 300),
368 hashlib.sha1)
369 with version_cache:
370 # Convert |version| to a string that may be used as a filename in disk
371 # cache by hashing it.
372 version_digest = hashlib.sha1(version).hexdigest()
373 try:
374 instance_id = version_cache.read(version_digest)
375 except isolateserver.CacheMiss:
376 instance_id = resolve_version(
377 service_url, package_name, version, timeout=timeoutfn())
378 version_cache.write(version_digest, instance_id)
379
380 # instance_cache is {instance_id -> client binary} mapping.
381 # It is bounded by 5 client versions.
382 instance_cache = isolateserver.DiskCache(
383 unicode(os.path.join(cache_dir, 'clients')),
384 isolateserver.CachePolicies(0, 0, 5),
385 hashlib.sha1)
386 with instance_cache:
387 if instance_id not in instance_cache:
388 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
389 fetch_url = get_client_fetch_url(
390 service_url, package_name, instance_id, timeout=timeoutfn())
391 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
392
393 # A single host can run multiple swarming bots, but ATM they do not share
394 # same root bot directory. Thus, it is safe to use the same name for the
395 # binary.
396 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
397 if fs.isfile(binary_path):
nodir6dfdb2d2016-06-14 20:14:08 -0700398 file_path.remove(binary_path)
nodirbe642ff2016-06-09 15:51:51 -0700399 instance_cache.hardlink(instance_id, binary_path, 0511) # -r-x--x--x
400
401 yield CipdClient(binary_path)