Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(386)

Side by Side Diff: client/cipd.py

Issue 2037253002: run_isolated.py: install CIPD packages (Closed) Base URL: https://chromium.googlesource.com/external/github.com/luci/luci-py@master
Patch Set: fix client fetching Created 4 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « appengine/swarming/server/bot_archive.py ('k') | client/isolateserver.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # 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
9 import contextlib
10 import hashlib
11 import logging
12 import optparse
13 import os
14 import platform
15 import sys
16 import tempfile
17 import time
18 import urllib
19
20 from utils import file_path
21 from utils import fs
22 from utils import net
23 from utils import subprocess42
24 from utils import tools
25 import isolated_format
26 import isolateserver
27
28
29 # .exe on Windows.
30 EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
31
32
33 class Error(Exception):
34 """Raised on CIPD errors."""
35
36
37 def 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
67 def 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
90 class 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
174 def 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
206 def 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
231 def 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
239 def 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
256 def _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
267 def 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
283 def 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
304 def _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
336 def 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):
398 fs.unlink(binary_path)
399 instance_cache.hardlink(instance_id, binary_path, 0511) # -r-x--x--x
400
401 yield CipdClient(binary_path)
OLDNEW
« no previous file with comments | « appengine/swarming/server/bot_archive.py ('k') | client/isolateserver.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698