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

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: get_client is a context manager 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 list_file_closed = False
M-A Ruel 2016/06/09 16:21:04 not needed
nodir 2016/06/09 16:36:46 Done
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 os.close(list_file_handle)
133 list_file_closed = True
M-A Ruel 2016/06/09 16:21:03 list_file_handle = None but in practice I'd prefe
nodir 2016/06/09 16:36:46 Done.
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 if not list_file_closed:
172 os.close(list_file_handle)
173 fs.remove(list_file_path)
174
175
176 def 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
208 def 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
233 def render_package_name_template(template):
234 """Expands template variables in a CIPD package name template."""
235 return (template
236 .replace('${platform}', get_platform())
237 .replace('${os_ver}', get_os_ver()))
238
239
240 def parse_package(package):
241 """Parses a package in --cipd-package format.
242
243 Returns:
244 (package_name_template, version) tuple.
245
246 Raises:
247 ValueError if package name or version is not specified.
248 """
249 if not package:
250 raise ValueError('package is not specified')
251 parts = package.split(':', 1)
252 if len(parts) != 2:
253 raise ValueError('version is not specified')
254 return tuple(parts)
255
256
257 def _check_response(res, fmt, *args):
258 """Raises Error if response is bad."""
259 if not res:
260 raise Error('%s: no response' % (fmt % args))
261
262 if res.get('status') != 'SUCCESS':
263 raise Error('%s: %s' % (
264 fmt % args,
265 res.get('error_message') or 'status is %s' % res.get('status')))
266
267
268 def resolve_version(cipd_server, package_name, version, timeout=None):
269 """Resolves a package instance version (e.g. a tag) to an instance id."""
270 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
271 cipd_server,
272 urllib.urlencode({
273 'package_name': package_name,
274 'version': version,
275 }))
276 res = net.url_read_json(url, timeout=timeout)
277 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
278 instance_id = res.get('instance_id')
279 if not instance_id:
280 raise Error('Invalid resolveVersion response: no instance id')
281 return instance_id
282
283
284 def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
285 """Returns a fetch URL of CIPD client binary contents.
286
287 Raises:
288 Error if cannot retrieve fetch URL.
289 """
290 # Fetch the URL of the binary from CIPD backend.
291 package_name = render_package_name_template(package_name)
292 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
293 'package_name': package_name,
294 'instance_id': instance_id,
295 }))
296 res = net.url_read_json(url, timeout=timeout)
297 _check_response(
298 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
299 fetch_url = res.get('client_binary', {}).get('fetch_url')
300 if not fetch_url:
301 raise Error('Invalid fetchClientBinary response: no fetch_url')
302 return fetch_url
303
304
305 def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
306 """Fetches cipd binary to |disk_cache|.
307
308 Retries requests with exponential back-off.
309
310 Raises:
311 Error if could not fetch content.
312 """
313 sleep_time = 1
314 for attempt in xrange(5):
315 if attempt > 0:
316 if timeoutfn() is not None and timeoutfn() < sleep_time:
317 raise Error('Could not fetch CIPD client: timeout')
318 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
319 time.sleep(sleep_time)
320 sleep_time *= 2
321
322 try:
323 res = net.url_open(fetch_url, timeout=timeoutfn())
324 if res:
325 disk_cache.write(instance_id, res.iter_content(64 * 1024))
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 cac he
M-A Ruel 2016/06/09 16:21:03 wrap at 80 cols
nodir 2016/06/09 16:36:46 Done.
371 # 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 sa me
394 # root bot directory. Thus, it is safe to use the same name for the binary.
395 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
396 if fs.isfile(binary_path):
397 fs.unlink(binary_path)
398 instance_cache.hardlink(instance_id, binary_path, 0511) # -r-x--x--x
399
400 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