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